@@ -31,9 +31,7 @@ function getResponseLengthInstruction(length: ResponseLength): string {
3131function getMaxTokens ( length : ResponseLength ) : number {
3232 switch ( length ) {
3333 case "short" :
34- // Korean uses 2-3x more tokens than English in Gemini's tokenizer.
35- // Bumped from 350 to 800 so short responses don't get cut mid-sentence
36- // in Korean. Output is still clamped by clampToWordLimit downstream.
34+ // Korean tokenizes 2-3x heavier; 800 keeps short mode from cutting mid-sentence.
3735 return 800
3836 case "long" :
3937 return 4096
@@ -72,16 +70,8 @@ function stripUnmatchedPair(text: string, token: string): string {
7270 return `${ text . slice ( 0 , lastIndex ) } ${ text . slice ( lastIndex + token . length ) } `
7371}
7472
75- /**
76- * Walks from the end of a truncated response and drops lines that are
77- * dangling markdown structure with no body: a heading that has no content
78- * below it, a lone bullet or numbered marker, or a table row that was cut
79- * mid-write (starts with `|` but doesn't close with `|`). Only used on
80- * responses that the stream clamper actually truncated, so normal answers
81- * are never touched.
82- *
83- * Exported for unit testing.
84- */
73+ /** Drops dangling markdown at a truncation point: a heading with no body, a
74+ * lone list marker, a half-written table row. Only runs on clamped text. */
8575export function stripDanglingStructure ( text : string ) : string {
8676 const lines = text . split ( "\n" )
8777 while ( lines . length > 0 ) {
@@ -90,24 +80,17 @@ export function stripDanglingStructure(text: string): string {
9080 lines . pop ( )
9181 continue
9282 }
93- // Any heading at the absolute end of the response is dangling by
94- // definition - a properly-followed heading would not be the last
95- // non-blank line. Applies to both `### Title` (heading cut before
96- // its body) and `### ` (marker alone).
83+ // A heading as the last non-blank line is dangling by definition.
9784 if ( / ^ # { 1 , 6 } (?: \s .* ) ? $ / . test ( last ) ) {
9885 lines . pop ( )
9986 continue
10087 }
101- // Lone list marker with nothing after it: `-`, `* `, `1.`, `2. `, etc .
88+ // Lone list marker.
10289 if ( / ^ (?: [ - * ] | \d + \. ) \s * $ / . test ( last ) ) {
10390 lines . pop ( )
10491 continue
10592 }
106- // Truncated table row: line starts with `|` and EITHER does not close
107- // with `|` (mid-cell cut) OR has fewer pipes than the pipe-line above
108- // it (cut between cells, still has a trailing `|` but not enough
109- // columns). Complete rows that match the column count of the row
110- // above are left alone.
93+ // Table row cut mid-cell (no closing pipe) or short of the row above's columns.
11194 if ( / ^ \s * \| / . test ( last ) ) {
11295 const endsWithPipe = / \| \s * $ / . test ( last )
11396 if ( ! endsWithPipe ) {
@@ -131,15 +114,8 @@ export function stripDanglingStructure(text: string): string {
131114 return lines . join ( "\n" ) . trimEnd ( )
132115}
133116
134- /**
135- * Polishes a response that the word-limit clamper had to truncate mid-write.
136- * Tries to end at the last complete sentence, strips dangling markdown
137- * structure (headings/bullets/table rows with no body), removes unmatched
138- * bold/italic/code markers, and appends an ellipsis if the result still
139- * doesn't end cleanly. Only called when `wasTruncated` is true.
140- *
141- * Exported for unit testing.
142- */
117+ /** Ends clamped text cleanly: last full sentence, unmatched-marker strip,
118+ * dangling-structure drop, ellipsis fallback. Truncated responses only. */
143119export function polishTruncatedResponse ( text : string , wordLimit : number ) : string {
144120 let result = text . trimEnd ( )
145121
@@ -188,12 +164,7 @@ export function polishTruncatedResponse(text: string, wordLimit: number): string
188164 return clampToWordLimit ( result , wordLimit ) . text
189165}
190166
191- // Formatting guidance varies by length. Short stays plain prose so tight
192- // answers don't get cluttered with structure. Medium unlocks bullets and
193- // numbered lists when the content is naturally a list. Long unlocks ###
194- // and #### subheadings, GFM tables, and fenced code, but NOT # or ##
195- // (those would compete with the app's own UI headers). The guiding line
196- // in every tier: "structure is a tool, not decoration."
167+ // Formatting ladder: short = prose only, medium = lists, long = ###/tables/code. # and ## stay forbidden - they compete with the app's own UI headers.
197168export function getFormattingInstruction ( length : ResponseLength , isKorean : boolean ) : string {
198169 if ( isKorean ) {
199170 switch ( length ) {
@@ -309,11 +280,7 @@ export async function POST(request: Request) {
309280 }
310281
311282 const inputMessages = messages . filter ( ( m ) => m . sender !== "system" && m . sender !== "verdict" )
312- // CRITICAL: only consider the USER's own messages when detecting the
313- // conversation language. Otherwise one AI that hallucinates a Korean
314- // summary mid-response tips the ratio and forces every subsequent
315- // provider in the debate to respond entirely in Korean - the
316- // cascading Korean drift bug observed on 2026-04-11.
283+ // Detect language from USER messages only - one model hallucinating Korean would otherwise cascade the whole debate into Korean.
317284 const userOnlyMessages = inputMessages . filter ( ( m ) => m . sender === "user" )
318285 const forceKorean = validatedLocale !== "ko" && isPredominantlyKorean ( userOnlyMessages )
319286 const streamFn = getStreamFn ( provider )
@@ -331,20 +298,12 @@ export async function POST(request: Request) {
331298 requestApiKey
332299 )
333300 if ( blockedResponse ) return blockedResponse
334- // Hard word caps per response length. These sit behind the prompt
335- // instruction as a belt-and-suspenders guard: when a provider (Gemini
336- // especially) decides to run 2x longer than requested, the server
337- // clamps the stream mid-flight instead of letting the oversized
338- // bubble dominate the debate feed.
301+ // Hard server-side clamp behind the prompt instruction; Gemini especially overruns.
339302 const wordLimit =
340303 validatedResponseLength === "short" ? 75 : validatedResponseLength === "medium" ? 170 : 500
341304 const encoder = new TextEncoder ( )
342305 let fullContent = ""
343- // Whether any clampToWordLimit pass actually truncated the stream.
344- // We only call polishTruncatedResponse when this is true, because
345- // polishing can strip unmatched markdown pairs and trailing
346- // connector words, which would unintentionally mutate normal
347- // (non-truncated) responses in medium/long mode.
306+ // Polishing mutates text (marker strip, connector trim) - only run it on actually-truncated responses.
348307 let wasTruncated = false
349308
350309 const stream = new ReadableStream ( {
@@ -417,11 +376,7 @@ export async function POST(request: Request) {
417376 fullContent = polishTruncatedResponse ( fullContent , wordLimit )
418377 }
419378
420- // Empty-stream guard: providers occasionally close the stream
421- // without yielding any text (safety filter, transient model glitch).
422- // streamGemini already retries once internally. Surface it as an
423- // explicit empty flag so the client can show a clear fallback
424- // instead of a placeholder stuck in "thinking..." forever.
379+ // Providers occasionally close without yielding (safety filter, glitch); flag it so the client shows a fallback instead of eternal "thinking...".
425380 const isEmpty = ! fullContent . trim ( )
426381
427382 enqueueEvent ( {
@@ -438,16 +393,7 @@ export async function POST(request: Request) {
438393 closeController ( )
439394 return
440395 }
441- // Route provider failures through the `error` channel rather
442- // than streaming them as bubble content. The old path wrote
443- // `${DISPLAY_NAMES[provider]} encountered an error: ${raw}`
444- // into a chat chunk, so upstream garbage like
445- // "[VertexAI.ClientError]: got status: 499 Client Closed
446- // Request. {"error":{"code":499,..}}" leaked verbatim into
447- // the user-visible bubble. On the client side, data.error
448- // throws into callModel's catch, which now substitutes the
449- // friendly "stepped out for a snack break" system message.
450- // The raw detail still lands in the server log for debugging.
396+ // Provider failures go through the error channel, never bubble content - raw upstream errors used to leak verbatim into the chat. Client shows the friendly fallback; detail stays in the server log.
451397 const msg = error instanceof Error ? error . message : "Unknown error"
452398 const sanitized = redactSecrets ( msg )
453399 console . error ( `[chat/${ provider } ] stream failed:` , sanitized )
0 commit comments