@oh-my-pi/pi-ai 16.2.9 → 16.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.12] - 2026-07-01
6
+
7
+ ### Changed
8
+
9
+ - Improved streaming performance for Cursor and Devin providers by optimizing mid-stream tool-call argument parsing to prevent UI stalls when handling large payloads.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed issues with tool call streaming where tool call IDs, partial JSON payloads, or late-arriving IDs could be lost, filtered, or incorrectly initialized.
14
+ - Fixed an issue where stream healing for leaked thinking blocks could replace live tool-call blocks with empty-id placeholders, breaking streamed tool arguments on Anthropic-compatible streams.
15
+ - Fixed an issue where stalled auth-gateway SSE responses could hang indefinitely in pi-native streams by ensuring first-event and idle timeout watchdogs are properly honored.
16
+ - Fixed cross-turn tool-call loops going undetected by adding a guard for consecutive identical tool calls. (#3971)
17
+
18
+ ## [16.2.11] - 2026-07-01
19
+
20
+ ### Fixed
21
+
22
+ - Fixed streaming UI glitches and resolved an issue where invalid empty tool call IDs were persisted in the chat history.
23
+
24
+ ## [16.2.10] - 2026-06-30
25
+
26
+ ### Added
27
+
28
+ - Added streaming support for keyed parameter argument deltas in XML-family in-band tool call scanners (Anthropic, DeepSeek, XML, Minimax)
29
+
30
+ ### Changed
31
+
32
+ - Improved native tool-call passthrough in `wrapInbandToolStream` to accurately mirror live streaming IDs, arguments, and partial JSON states from the underlying provider
33
+
34
+ ### Fixed
35
+
36
+ - Fixed a bug where tool calls with empty or missing IDs were not detected as malformed, causing API validation failures (e.g., 400 errors with Anthropic) on subsequent requests
37
+ - Raised Gemini header runaway threshold to prevent premature interruption of complex reasoning loops
38
+ - Fixed leaked ` ```thinking ` fences with nested language-tagged Markdown code blocks so inner fences remain inside structured thinking instead of leaking as visible reply text.
39
+
5
40
  ## [16.2.9] - 2026-06-30
6
41
 
7
42
  ### Added
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Close-matcher for a ` ```thinking ` block that respects nested Markdown code
3
+ * fences. A naive `indexOf("```")` closes the thinking section at the FIRST
4
+ * backtick fence inside the reasoning, so an inner ` ```rs … ``` ` code block
5
+ * leaks its body (and everything after) into the visible channel. This scanner
6
+ * tracks inner-fence nesting so only the real thinking closer ends the block.
7
+ *
8
+ * Distinguishing an inner opener from the closer: a fenced code block opener is
9
+ * ` ``` ` immediately followed by a language token (`rs`, `tool_code`, `c++` …)
10
+ * and a newline. The thinking closer is a bare ` ``` `, or ` ``` ` glued to the
11
+ * visible reply — its remainder is prose (contains whitespace/punctuation), not
12
+ * a language token. So a top-level fence whose info is a single language-token
13
+ * word opens a nested block; anything else closes the thinking, and the text
14
+ * after the fence run is the visible reply. This preserves the long-standing
15
+ * inline-close behavior (` ```Visible reply ` ends the block) while skipping
16
+ * language-tagged inner fences.
17
+ *
18
+ * Used by both the owned Gemini scanner (live ` ```thinking ` stream) and the
19
+ * generic `ThinkingInbandScanner` (leaked-idiom healing).
20
+ *
21
+ * Limitation: an *info-less* nested fence (a bare ` ``` ` opening a code block
22
+ * inside the reasoning) is indistinguishable from the thinking closer and ends
23
+ * the block. Models tag their fences with a language in practice, so this is
24
+ * strictly better than the previous first-` ``` ` behavior.
25
+ */
26
+ /** Result of feeding bytes to {@link FencedThinkingScanner}. */
27
+ export interface FencedThinkingResult {
28
+ /** Thinking text to emit for this feed (may be empty). */
29
+ readonly thinking: string;
30
+ /** True once the thinking closer has been consumed. */
31
+ readonly closed: boolean;
32
+ /** Bytes after the closing fence (visible reply); only meaningful when {@link closed}. */
33
+ readonly rest: string;
34
+ }
35
+ /**
36
+ * Stateful, line-oriented close-matcher for one ` ```thinking ` block. Owns the
37
+ * partial-line buffer so an ambiguous trailing fence is held until it resolves.
38
+ *
39
+ * Streaming stays character-level for ordinary content: a line is emitted as its
40
+ * bytes arrive, yet retained in the buffer until its newline so the complete
41
+ * line can be classified ({@link #emitted} tracks how many leading bytes are
42
+ * already emitted). A top-level fence candidate is held until its info
43
+ * disambiguates opener (language token) from closer (prose / bare).
44
+ */
45
+ export declare class FencedThinkingScanner {
46
+ #private;
47
+ /**
48
+ * Feed bytes and return thinking deltas plus close state. When `final`, the
49
+ * held tail resolves: a bare ` ``` ` or a ` ```<reply> ` fence closes the
50
+ * block (remainder becomes `rest`), otherwise it is unterminated thinking.
51
+ */
52
+ feed(text: string, final: boolean): FencedThinkingResult;
53
+ }
@@ -45,4 +45,5 @@ export * from "./utils/openrouter-headers";
45
45
  export * from "./utils/retry";
46
46
  export * from "./utils/schema";
47
47
  export * from "./utils/thinking-loop";
48
+ export * from "./utils/tool-call-loop-guard";
48
49
  export * from "./utils/validation";
@@ -1,6 +1,6 @@
1
1
  import { type JsonValue } from "@bufbuild/protobuf";
2
2
  import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types";
3
- import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingPartialJson } from "../utils/block-symbols";
3
+ import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
4
4
  import { AssistantMessageEventStream } from "../utils/event-stream";
5
5
  export declare const CURSOR_API_URL = "https://api2.cursor.sh";
6
6
  export declare const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
@@ -14,6 +14,7 @@ export declare const streamCursor: StreamFunction<"cursor-agent">;
14
14
  export type ToolCallState = ToolCall & {
15
15
  [kStreamingBlockIndex]: number;
16
16
  [kStreamingPartialJson]?: string;
17
+ [kStreamingLastParseLen]?: number;
17
18
  [kStreamingBlockKind]: "mcp" | "todo";
18
19
  };
19
20
  export interface BlockState {
@@ -41,12 +41,17 @@ export declare class ThinkingLoopDetector {
41
41
  * stream that trips the tool-call reminder. Gemini occasionally narrates a long
42
42
  * chain of titled summaries ("Examining Result Handling", "Refining Result
43
43
  * Rendering", …) without ever calling a tool, burning the whole budget on
44
- * planning; at this many distinct titles it has almost certainly stalled. This
45
- * is the over-planning shape {@link ThinkingLoopDetector} misses those titles
46
- * are stripped before its similarity analysis precisely because their wording
47
- * keeps changing, so a genuinely-distinct planning runaway never trips it.
44
+ * planning. This is the over-planning shape {@link ThinkingLoopDetector} misses
45
+ * those titles are stripped before its similarity analysis precisely because their
46
+ * wording keeps changing, so a genuinely-distinct planning runaway never trips it.
47
+ *
48
+ * Set well above legitimate hard-problem depth: a capable model can emit ~10
49
+ * distinct, progressing hypotheses in a single reasoning block before acting (and
50
+ * a false trip is costly — the interrupt discards the whole reasoning turn). A
51
+ * real narration runaway burns dozens-to-hundreds of titles, so this still trips
52
+ * fast on the actual pathology.
48
53
  */
49
- export declare const GEMINI_HEADER_RUNAWAY_THRESHOLD = 10;
54
+ export declare const GEMINI_HEADER_RUNAWAY_THRESHOLD = 24;
50
55
  /**
51
56
  * True when a single trimmed line is a Gemini reasoning-summary title: a markdown
52
57
  * ATX heading (`## …`) or a whole-line bold / bold-italic run (`**Title**`,
@@ -0,0 +1,26 @@
1
+ import type { AssistantMessage, ToolResultMessage } from "../types";
2
+ /** Runtime settings for cross-turn tool-call repetition detection. */
3
+ export interface ToolCallLoopGuardOptions {
4
+ readonly threshold: number;
5
+ readonly exemptTools: readonly string[];
6
+ }
7
+ /** A completed assistant turn plus the tool results it produced. */
8
+ export interface ToolCallLoopTurn {
9
+ readonly message: AssistantMessage;
10
+ readonly toolResults: readonly ToolResultMessage[];
11
+ }
12
+ /** Details needed to steer the model away from a repeated tool call. */
13
+ export interface RepeatedToolCallDetection {
14
+ readonly kind: "repeated_tool_call";
15
+ readonly toolName: string;
16
+ readonly count: number;
17
+ readonly resultSummary: string;
18
+ readonly argumentsSummary: string;
19
+ }
20
+ /** Detects consecutive identical assistant tool calls across model turns. */
21
+ export declare class ToolCallLoopGuard {
22
+ #private;
23
+ constructor(options: ToolCallLoopGuardOptions);
24
+ /** Records one completed turn and returns the threshold hit, if any. */
25
+ recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null;
26
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.2.9",
4
+ "version": "16.2.12",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.2.9",
42
- "@oh-my-pi/pi-utils": "16.2.9",
43
- "@oh-my-pi/pi-wire": "16.2.9",
41
+ "@oh-my-pi/pi-catalog": "16.2.12",
42
+ "@oh-my-pi/pi-utils": "16.2.12",
43
+ "@oh-my-pi/pi-wire": "16.2.12",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -132,7 +132,7 @@ export class AnthropicInbandScanner implements InbandScanner {
132
132
  progressed = this.#consumeInvoke(final, events);
133
133
  break;
134
134
  case "parameter":
135
- progressed = this.#consumeParameter(final);
135
+ progressed = this.#consumeParameter(final, events);
136
136
  break;
137
137
  case "thinking":
138
138
  progressed = this.#consumeThinking(final, events);
@@ -272,7 +272,7 @@ export class AnthropicInbandScanner implements InbandScanner {
272
272
  return true;
273
273
  }
274
274
 
275
- #consumeParameter(final: boolean): boolean {
275
+ #consumeParameter(final: boolean, events: InbandScanEvent[]): boolean {
276
276
  const tagStart = this.#buffer.indexOf("<");
277
277
  if (tagStart === -1) {
278
278
  if (final) {
@@ -280,14 +280,14 @@ export class AnthropicInbandScanner implements InbandScanner {
280
280
  this.#buffer = "";
281
281
  return false;
282
282
  }
283
- this.#appendParameterValue(this.#buffer);
283
+ this.#appendParameterValue(this.#buffer, events);
284
284
  this.#rawBlock += this.#buffer;
285
285
  this.#buffer = "";
286
286
  return false;
287
287
  }
288
288
  if (tagStart > 0) {
289
289
  const consumed = this.#buffer.slice(0, tagStart);
290
- this.#appendParameterValue(consumed);
290
+ this.#appendParameterValue(consumed, events);
291
291
  this.#rawBlock += consumed;
292
292
  this.#buffer = this.#buffer.slice(tagStart);
293
293
  return true;
@@ -307,7 +307,7 @@ export class AnthropicInbandScanner implements InbandScanner {
307
307
  return false;
308
308
  }
309
309
  const consumed = this.#buffer[0]!;
310
- this.#appendParameterValue(consumed);
310
+ this.#appendParameterValue(consumed, events);
311
311
  this.#rawBlock += consumed;
312
312
  this.#buffer = this.#buffer.slice(1);
313
313
  return true;
@@ -378,10 +378,22 @@ export class AnthropicInbandScanner implements InbandScanner {
378
378
  this.#state = "parameter";
379
379
  }
380
380
 
381
- #appendParameterValue(delta: string): void {
381
+ #appendParameterValue(delta: string, events: InbandScanEvent[]): void {
382
382
  if (delta.length === 0) return;
383
383
  const remaining = MAX_PARAMETER_VALUE_LENGTH - this.#paramValue.length;
384
- if (remaining > 0) this.#paramValue += delta.slice(0, remaining);
384
+ const accepted = remaining > 0 ? delta.slice(0, remaining) : "";
385
+ if (accepted.length > 0) {
386
+ this.#paramValue += accepted;
387
+ if (this.#started && this.#paramName.length > 0) {
388
+ events.push({
389
+ type: "toolArgDelta",
390
+ id: this.#id,
391
+ name: this.#name,
392
+ key: this.#paramName,
393
+ delta: accepted,
394
+ });
395
+ }
396
+ }
385
397
  if (delta.length > remaining) this.#paramTruncated = true;
386
398
  }
387
399
 
@@ -99,6 +99,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
99
99
  #dsmlArgs: Record<string, unknown> = {};
100
100
  #dsmlParamName = "";
101
101
  #dsmlParamIsString = true;
102
+ #dsmlParamRaw = "";
102
103
  #rawBlock = "";
103
104
  #stripLeadingWhitespace = false;
104
105
 
@@ -153,7 +154,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
153
154
  if (!this.#consumeDsmlInvoke(final, events)) break;
154
155
  continue;
155
156
  }
156
- if (!this.#consumeDsmlParam(final)) break;
157
+ if (!this.#consumeDsmlParam(final, events)) break;
157
158
  }
158
159
  if (final && this.#state === "thinking") this.#endThinking(events);
159
160
  if (final && this.#buffer.length === 0 && this.#rawBlock.length > 0) this.#rawBlock = "";
@@ -396,18 +397,23 @@ export class DeepSeekInbandScanner implements InbandScanner {
396
397
  return final;
397
398
  }
398
399
 
399
- #consumeDsmlParam(final: boolean): boolean {
400
+ #consumeDsmlParam(final: boolean, events: InbandScanEvent[]): boolean {
400
401
  const close = findEarliestToken(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
401
402
  if (!close) {
403
+ const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, DSML_PARAMETER_CLOSE_TOKENS);
404
+ const chunk = this.#buffer.slice(0, this.#buffer.length - hold);
405
+ this.#streamDsmlParam(chunk, events);
406
+ this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
402
407
  if (final) this.#resetDsmlTool();
403
408
  return false;
404
409
  }
405
- const rawValue = this.#buffer.slice(0, close.index);
406
- this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(rawValue, this.#dsmlParamIsString);
407
- this.#rawBlock += rawValue + close.token;
410
+ this.#streamDsmlParam(this.#buffer.slice(0, close.index), events);
411
+ this.#dsmlArgs[this.#dsmlParamName] = coerceDsmlValue(this.#dsmlParamRaw, this.#dsmlParamIsString);
412
+ this.#rawBlock += close.token;
408
413
  this.#buffer = this.#buffer.slice(close.index + close.token.length);
409
414
  this.#dsmlParamName = "";
410
415
  this.#dsmlParamIsString = true;
416
+ this.#dsmlParamRaw = "";
411
417
  this.#state = "dsmlInvoke";
412
418
  return true;
413
419
  }
@@ -418,6 +424,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
418
424
  events.push({ type: "toolStart", id: this.#id, name: this.#name });
419
425
  }
420
426
 
427
+ #streamDsmlParam(chunk: string, events: InbandScanEvent[]): void {
428
+ if (chunk.length === 0) return;
429
+ this.#dsmlParamRaw += chunk;
430
+ this.#rawBlock += chunk;
431
+ events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#dsmlParamName, delta: chunk });
432
+ }
433
+
421
434
  #emitThinking(delta: string, events: InbandScanEvent[]): void {
422
435
  if (delta.length === 0) return;
423
436
  if (this.#parseThinking) {
@@ -509,6 +522,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
509
522
  this.#dsmlArgs = {};
510
523
  this.#dsmlParamName = "";
511
524
  this.#dsmlParamIsString = true;
525
+ this.#dsmlParamRaw = "";
512
526
  this.#rawBlock = "";
513
527
  }
514
528
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Close-matcher for a ` ```thinking ` block that respects nested Markdown code
3
+ * fences. A naive `indexOf("```")` closes the thinking section at the FIRST
4
+ * backtick fence inside the reasoning, so an inner ` ```rs … ``` ` code block
5
+ * leaks its body (and everything after) into the visible channel. This scanner
6
+ * tracks inner-fence nesting so only the real thinking closer ends the block.
7
+ *
8
+ * Distinguishing an inner opener from the closer: a fenced code block opener is
9
+ * ` ``` ` immediately followed by a language token (`rs`, `tool_code`, `c++` …)
10
+ * and a newline. The thinking closer is a bare ` ``` `, or ` ``` ` glued to the
11
+ * visible reply — its remainder is prose (contains whitespace/punctuation), not
12
+ * a language token. So a top-level fence whose info is a single language-token
13
+ * word opens a nested block; anything else closes the thinking, and the text
14
+ * after the fence run is the visible reply. This preserves the long-standing
15
+ * inline-close behavior (` ```Visible reply ` ends the block) while skipping
16
+ * language-tagged inner fences.
17
+ *
18
+ * Used by both the owned Gemini scanner (live ` ```thinking ` stream) and the
19
+ * generic `ThinkingInbandScanner` (leaked-idiom healing).
20
+ *
21
+ * Limitation: an *info-less* nested fence (a bare ` ``` ` opening a code block
22
+ * inside the reasoning) is indistinguishable from the thinking closer and ends
23
+ * the block. Models tag their fences with a language in practice, so this is
24
+ * strictly better than the previous first-` ``` ` behavior.
25
+ */
26
+
27
+ /** A complete fence line: ≤3 lead spaces, a run of ≥3 backticks/tildes, then an info string. */
28
+ const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
29
+ /** ≤3 lead spaces then a (possibly partial) backtick run and whatever follows it. */
30
+ const BACKTICK_LEAD = /^ {0,3}(`*)([\s\S]*)$/;
31
+ /** A language-tag info string: one token, no whitespace (markers an inner fence opener carries). */
32
+ const LANG_TOKEN = /^[A-Za-z0-9_+#-]+$/;
33
+
34
+ /** Result of feeding bytes to {@link FencedThinkingScanner}. */
35
+ export interface FencedThinkingResult {
36
+ /** Thinking text to emit for this feed (may be empty). */
37
+ readonly thinking: string;
38
+ /** True once the thinking closer has been consumed. */
39
+ readonly closed: boolean;
40
+ /** Bytes after the closing fence (visible reply); only meaningful when {@link closed}. */
41
+ readonly rest: string;
42
+ }
43
+
44
+ /**
45
+ * Stateful, line-oriented close-matcher for one ` ```thinking ` block. Owns the
46
+ * partial-line buffer so an ambiguous trailing fence is held until it resolves.
47
+ *
48
+ * Streaming stays character-level for ordinary content: a line is emitted as its
49
+ * bytes arrive, yet retained in the buffer until its newline so the complete
50
+ * line can be classified ({@link #emitted} tracks how many leading bytes are
51
+ * already emitted). A top-level fence candidate is held until its info
52
+ * disambiguates opener (language token) from closer (prose / bare).
53
+ */
54
+ export class FencedThinkingScanner {
55
+ #buffer = "";
56
+ /** The fence run that opened the current nested code block, or "" at top level. */
57
+ #inner = "";
58
+ /** Bytes of the leading (incomplete) line already returned as thinking. */
59
+ #emitted = 0;
60
+
61
+ /**
62
+ * Feed bytes and return thinking deltas plus close state. When `final`, the
63
+ * held tail resolves: a bare ` ``` ` or a ` ```<reply> ` fence closes the
64
+ * block (remainder becomes `rest`), otherwise it is unterminated thinking.
65
+ */
66
+ feed(text: string, final: boolean): FencedThinkingResult {
67
+ this.#buffer += text;
68
+ let thinking = "";
69
+ for (;;) {
70
+ const nl = this.#buffer.indexOf("\n");
71
+ if (nl === -1) break;
72
+ const line = this.#buffer.slice(0, nl);
73
+ if (!this.#inner) {
74
+ const close = this.#closeRest(line);
75
+ if (close !== undefined) {
76
+ // Closer bytes are always held, so #emitted is 0 and nothing leaked.
77
+ const rest = close + this.#buffer.slice(nl); // keep the newline with the reply
78
+ this.#reset();
79
+ return { thinking, closed: true, rest };
80
+ }
81
+ }
82
+ // Content line (including an inner-fence open/close).
83
+ thinking += this.#buffer.slice(this.#emitted, nl + 1);
84
+ this.#updateInner(line);
85
+ this.#buffer = this.#buffer.slice(nl + 1);
86
+ this.#emitted = 0;
87
+ }
88
+
89
+ const tail = this.#buffer;
90
+ if (this.#inner) {
91
+ // Inside a nested block every byte is thinking content: emit eagerly,
92
+ // keeping it buffered until the newline classifies the line.
93
+ thinking += tail.slice(this.#emitted);
94
+ this.#emitted = tail.length;
95
+ return { thinking, closed: false, rest: "" };
96
+ }
97
+
98
+ if (final) {
99
+ const close = this.#closeRestFinal(tail);
100
+ if (close !== undefined) {
101
+ this.#reset();
102
+ return { thinking, closed: true, rest: close };
103
+ }
104
+ } else {
105
+ const close = this.#closeRestStreamingTail(tail);
106
+ if (close !== undefined) {
107
+ this.#reset();
108
+ return { thinking, closed: true, rest: close };
109
+ }
110
+ if (this.#mustHold(tail)) return { thinking, closed: false, rest: "" };
111
+ }
112
+ // Either final (flush the remainder) or a line that can no longer be a fence.
113
+ thinking += tail.slice(this.#emitted);
114
+ if (final) this.#reset();
115
+ else this.#emitted = tail.length;
116
+ return { thinking, closed: false, rest: "" };
117
+ }
118
+
119
+ /**
120
+ * Complete line close test. A bare backtick fence closes thinking; a
121
+ * language-token fence line opens an inner block; prose-like remainder is the
122
+ * inline visible reply.
123
+ */
124
+ #closeRest(line: string): string | undefined {
125
+ const m = BACKTICK_LEAD.exec(line);
126
+ if (!m || m[1]!.length < 3) return undefined;
127
+ const rest = m[2]!;
128
+ if (rest === "" || rest.trim() === "") return ""; // bare close (only whitespace)
129
+ if (LANG_TOKEN.test(rest)) return undefined; // language-tagged inner opener
130
+ return rest;
131
+ }
132
+
133
+ /** Final tail close test: EOF disambiguates any top-level backtick run as the closer. */
134
+ #closeRestFinal(tail: string): string | undefined {
135
+ const m = BACKTICK_LEAD.exec(tail);
136
+ if (!m || m[1]!.length < 3) return undefined;
137
+ const rest = m[2]!;
138
+ return rest.trim() === "" ? "" : rest;
139
+ }
140
+
141
+ /** Streaming tail close test: only a prose-like inline reply resolves the close. */
142
+ #closeRestStreamingTail(tail: string): string | undefined {
143
+ const m = BACKTICK_LEAD.exec(tail);
144
+ if (!m || m[1]!.length < 3) return undefined;
145
+ const rest = m[2]!;
146
+ if (rest === "" || rest.trim() === "" || LANG_TOKEN.test(rest)) return undefined;
147
+ return rest;
148
+ }
149
+
150
+ /** Whether a top-level trailing partial is still undecided and must be held. */
151
+ #mustHold(tail: string): boolean {
152
+ const m = BACKTICK_LEAD.exec(tail);
153
+ if (!m) return false;
154
+ const ticks = m[1]!.length;
155
+ const rest = m[2]!;
156
+ // A growing backtick run could still reach a fence. A complete run plus
157
+ // a language-token prefix is also undecided until a newline confirms an
158
+ // inner opener or a non-token character confirms an inline close.
159
+ if (rest === "" || rest.trim() === "") return ticks >= 1 || /^ {0,3}$/.test(tail);
160
+ return ticks >= 3 && LANG_TOKEN.test(rest);
161
+ }
162
+
163
+ #reset(): void {
164
+ this.#buffer = "";
165
+ this.#inner = "";
166
+ this.#emitted = 0;
167
+ }
168
+
169
+ /** Toggle nested-fence state for a completed content line. */
170
+ #updateInner(line: string): void {
171
+ const fence = FENCE_LINE.exec(line);
172
+ if (!fence) return;
173
+ const run = fence[1]!;
174
+ const info = fence[2]!.trim();
175
+ if (!this.#inner) {
176
+ // A top-level closer was already handled by #closeRest, so this opens a
177
+ // nested code block (tilde fence, or backtick fence with a language token).
178
+ this.#inner = run;
179
+ } else if (run[0] === this.#inner[0] && run.length >= this.#inner.length && info === "") {
180
+ // Closing fence: same char, at least as long, no info string.
181
+ this.#inner = "";
182
+ }
183
+ }
184
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Message, ToolCall } from "../types";
2
- import { mintToolCallId, partialSuffixOverlap, partialSuffixOverlapAny } from "./coercion";
2
+ import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
3
+ import { FencedThinkingScanner } from "./fenced-thinking";
3
4
  import dialectPrompt from "./gemini.md" with { type: "text" };
4
5
  import { assistantTranscriptParts, collectToolResultRun, joinUserBodies, messageContentText } from "./rendering";
5
6
  import type {
@@ -37,6 +38,8 @@ export class GeminiInbandScanner implements InbandScanner {
37
38
  #buffer = "";
38
39
  #state: State = "outside";
39
40
  #thinking = "";
41
+ /** Fence-aware close-matcher while {@link #state} is "thinking"; undefined otherwise. */
42
+ #fenced: FencedThinkingScanner | undefined;
40
43
  readonly #parseThinking: boolean;
41
44
 
42
45
  constructor(options: InbandScannerOptions = {}) {
@@ -55,21 +58,23 @@ export class GeminiInbandScanner implements InbandScanner {
55
58
 
56
59
  #consume(final: boolean): InbandScanEvent[] {
57
60
  const events: InbandScanEvent[] = [];
58
- while (this.#buffer.length > 0) {
59
- if (this.#state === "outside") {
60
- this.#consumeOutside(final, events);
61
- if (this.#state === "outside") break;
62
- continue;
63
- }
61
+ for (;;) {
64
62
  if (this.#state === "thinking") {
63
+ // Always run on final so the fenced scanner flushes its held tail even
64
+ // when #buffer is empty (a partial close held from the previous feed).
65
65
  this.#consumeThinking(final, events);
66
66
  if (this.#state === "thinking") break;
67
67
  continue;
68
68
  }
69
+ if (this.#buffer.length === 0) break;
70
+ if (this.#state === "outside") {
71
+ this.#consumeOutside(final, events);
72
+ if (this.#state === "outside") break;
73
+ continue;
74
+ }
69
75
  this.#consumeTool(final, events);
70
76
  if (this.#state === "tool") break;
71
77
  }
72
- if (final && this.#state === "thinking") this.#endThinking(events);
73
78
  return events;
74
79
  }
75
80
 
@@ -94,6 +99,7 @@ export class GeminiInbandScanner implements InbandScanner {
94
99
  if (isThink) {
95
100
  this.#buffer = this.#buffer.slice(start + THINK_OPEN.length);
96
101
  this.#thinking = "";
102
+ this.#fenced = new FencedThinkingScanner();
97
103
  events.push({ type: "thinkingStart" });
98
104
  this.#state = "thinking";
99
105
  return;
@@ -103,18 +109,13 @@ export class GeminiInbandScanner implements InbandScanner {
103
109
  }
104
110
 
105
111
  #consumeThinking(final: boolean, events: InbandScanEvent[]): void {
106
- const close = this.#buffer.indexOf(FENCE);
107
- if (close === -1) {
108
- const hold = final ? 0 : partialSuffixOverlap(this.#buffer, FENCE);
109
- this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
110
- this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
111
- if (final) this.#endThinking(events);
112
- return;
112
+ const result = this.#fenced!.feed(this.#buffer, final);
113
+ this.#buffer = result.closed ? result.rest : "";
114
+ this.#emitThinking(result.thinking, events);
115
+ if (result.closed || final) {
116
+ this.#endThinking(events);
117
+ this.#fenced = undefined;
113
118
  }
114
- this.#emitThinking(this.#buffer.slice(0, close), events);
115
- this.#buffer = this.#buffer.slice(close + FENCE.length);
116
- this.#endThinking(events);
117
- this.#state = "outside";
118
119
  }
119
120
 
120
121
  #emitThinking(delta: string, events: InbandScanEvent[]): void {