2727 * mention the tag, a quoted template, a lane with a broken chat template.
2828 * The two are indistinguishable while the deltas arrive, so everything
2929 * after the open is HELD rather than emitted (same mechanism as rule 3).
30- * A close settles the hold as reasoning; a step that ends without one
31- * releases it as text, so an answer is delayed but never swallowed.
32- * (History still stores the raw text either way — this is a
33- * display/reclassification concern, not context loss.)
30+ * A close settles the hold as reasoning. Past
31+ * {@link EXPLICIT_OPEN_HOLD_CHARS} without one, the hold COMMITS as
32+ * reasoning and the rest of the block streams live — a genuine chain of
33+ * thought runs arbitrarily long, and buffering one until its close would
34+ * freeze the thinking box for the whole trace. A step that ends still
35+ * undecided (never closed, never crossed the bound) releases the hold as
36+ * text, so an answer is delayed but never swallowed.
3437 * 3. An orphan `</think>` with no open tag — the DeepSeek shape above, where
3538 * the open tag was consumed by the chat template's prefill. The text
3639 * BEFORE it is reasoning, but by the time the marker arrives that text has
@@ -68,13 +71,22 @@ export interface ThinkTagStreamOptions {
6871}
6972
7073/**
71- * How much leading content to hold while waiting for an orphan `</think>`.
74+ * How much content an open block may hold while its classification is
75+ * undecided, before the hold gives up.
7276 *
73- * A leaked chain of thought runs well past this, so the cap is not there to
74- * fit one — it bounds the wrong case. If the marker has not arrived by here
75- * the step is answering, not thinking, and the buffer is released as text.
77+ * For the implicit head ({@link ThinkTagStreamOptions.implicitOpen}) the cap
78+ * bounds the wrong case: a leaked chain of thought runs well past it, so if
79+ * the orphan `</think>` has not arrived by here the step is answering, not
80+ * thinking, and the buffer is released as text.
81+ *
82+ * For an explicit `<think>` open the same magnitude is a latency cap, not a
83+ * size estimate: past it the hold commits as reasoning ({@link
84+ * EXPLICIT_OPEN_HOLD_CHARS}) rather than releasing as text, because a genuine
85+ * block legitimately runs long — the wrong case to bound is the waiting, not
86+ * the trace.
7687 */
7788export const IMPLICIT_OPEN_BUDGET_CHARS = 4000
89+ export const EXPLICIT_OPEN_HOLD_CHARS = IMPLICIT_OPEN_BUDGET_CHARS
7890
7991/**
8092 * Remove think scaffolding from a fragment, leaving everything else — including
@@ -167,8 +179,9 @@ export class ThinkTagStream {
167179 private inThinkBlock : boolean
168180 /** True while `held` is waiting for the close that settles its
169181 * classification. Armed by construction (implicitOpen) and by every open
170- * tag; cleared by the close ({@link confirmOpenHold}) or by giving up
171- * ({@link abandonOpenHold}) — budget, a native reasoning chunk, or flush. */
182+ * tag; settled by the close or by the explicit hold bound
183+ * ({@link confirmOpenHold}), or given up ({@link abandonOpenHold}) —
184+ * budget, a native reasoning chunk, or flush. */
172185 private holdingForOpen : boolean
173186
174187 constructor ( options : ThinkTagStreamOptions = { } ) {
@@ -240,20 +253,26 @@ export class ThinkTagStream {
240253 }
241254
242255 /** Emit everything withheld. A partial tag that never completed was always
243- * just text, and content held for a close that never came is the answer —
244- * releasing both here is what makes the hold lossless. An unclosed open is
245- * treated exactly like an orphan close: the marker is scaffolding, the
246- * text around it is the answer. */
256+ * just text — except inside a block that committed as reasoning, where a
257+ * truncated close belongs with the trace it would have ended. Content
258+ * held for a close that never came is the answer: releasing it here is
259+ * what makes the hold lossless. An unclosed open is treated exactly like
260+ * an orphan close — the marker is scaffolding, the text around it is the
261+ * answer. */
247262 flush ( ) : ThinkStreamSegment [ ] {
248263 const segments : ThinkStreamSegment [ ] = [ ]
249264 if ( this . holdingForOpen ) segments . push ( ...this . abandonOpenHold ( ) )
250265 const trailing = this . partial
251266 this . partial = ''
252- if ( trailing ) this . addText ( segments , trailing )
267+ if ( trailing ) {
268+ if ( this . inThinkBlock ) this . addReasoning ( segments , trailing )
269+ else this . addText ( segments , trailing )
270+ }
253271 return segments
254272 }
255273
256- /** The close arrived: what was held was reasoning after all. */
274+ /** The close arrived — or the hold bound crossed without one: what was
275+ * held was reasoning after all, and the rest of the block streams live. */
257276 private confirmOpenHold ( segments : ThinkStreamSegment [ ] ) : void {
258277 if ( ! this . holdingForOpen ) return
259278 this . holdingForOpen = false
@@ -273,10 +292,7 @@ export class ThinkTagStream {
273292 return held ? [ { type : 'text' , text : held } ] : [ ]
274293 }
275294
276- private addReasoning (
277- segments : ThinkStreamSegment [ ] ,
278- text : string ,
279- ) : void {
295+ private addReasoning ( segments : ThinkStreamSegment [ ] , text : string ) : void {
280296 // A nested/duplicated open tag inside a block is scaffolding, never thought.
281297 const cleaned = text . split ( OPEN_TAG ) . join ( '' )
282298 if ( ! cleaned ) return
@@ -285,17 +301,21 @@ export class ThinkTagStream {
285301 return
286302 }
287303 // Undecided: reasoning only if a close confirms it, so hold rather than
288- // send. The budget bounds only the implicit-head speculation — a held
289- // chain of thought from a leaking lane runs well past it, so past the
290- // budget the step is answering, not thinking, and the hold is released as
291- // text. An explicit open gets no budget: a genuine think block can
292- // legitimately run long, and only its close (or flush) settles it.
304+ // send. Two give-up points settle it, in opposite directions. The
305+ // implicit-head budget releases as TEXT — a held chain of thought from a
306+ // leaking lane runs well past it, so past the budget the step is
307+ // answering, not thinking. The explicit-open hold COMMITS as reasoning —
308+ // a genuine block runs arbitrarily long, and buffering one until its
309+ // close would freeze the thinking box for the whole trace, so past the
310+ // bound the paired-block reading wins and the rest streams live.
293311 this . held += cleaned
294- if (
295- this . implicitOpen &&
296- this . held . length >= IMPLICIT_OPEN_BUDGET_CHARS
297- ) {
312+ if ( this . implicitOpen && this . held . length >= IMPLICIT_OPEN_BUDGET_CHARS ) {
298313 segments . push ( ...this . abandonOpenHold ( ) )
314+ } else if (
315+ ! this . implicitOpen &&
316+ this . held . length >= EXPLICIT_OPEN_HOLD_CHARS
317+ ) {
318+ this . confirmOpenHold ( segments )
299319 }
300320 }
301321
0 commit comments