From fef3a59cbea4f19d562efe2ec037320205788fff Mon Sep 17 00:00:00 2001 From: MidhunCN Date: Tue, 13 Oct 2026 00:36:54 +0530 Subject: [PATCH] fix(streaming): prevent text truncation before tool calls in think filter and extractors Avoid unconditional 8-character tail buffering in ThinkTagFilter by only buffering when the chunk tail starts with an opening or closing think tag prefix. Flush think filter carry prior to emitting tool calls in both OpenAI and Anthropic extractors so text preceding tool calls is not delayed or split across widget cards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE-MAP.md | 51 ++++--- CHANGELOG.md | 2 + ...13-streaming-text-truncation-tool-calls.md | 32 ++++ src/test/extractors.test.ts | 142 ++++++++++++++++++ src/test/thinkTags.test.ts | 121 +++++++++++++++ src/transports/extractors.ts | 63 +++++--- src/transports/thinkTags.ts | 31 ++-- 7 files changed, 382 insertions(+), 60 deletions(-) create mode 100644 docs/issues/111-20261013-streaming-text-truncation-tool-calls.md create mode 100644 src/test/thinkTags.test.ts diff --git a/ARCHITECTURE-MAP.md b/ARCHITECTURE-MAP.md index caace8533..ba8ff0560 100644 --- a/ARCHITECTURE-MAP.md +++ b/ARCHITECTURE-MAP.md @@ -412,31 +412,32 @@ Reuse these before writing new logic (all under `src/` root unless noted): ## 8. Tests, Tooling & CI -### Unit tests (`src/test/` — 37 files, 480 cases) - -Pure/domain modules get co-located tests. **Harness:** Node's built-in `node --test` runner via `scripts/run-unit-tests.ts`, which collects the compiled `out/test/*.test.js` files; tests import from compiled `out/` modules with explicit `.js` extensions (Node16/ESM-style) and use `node:test` + `node:assert/strict`. Tests never need a live model — they cover the deterministic parts (message conversion, chunk parsing, token estimation, routing). Per-file case counts (480 total, audited 2026-09-23): - -| Test file | Cases | | Test file | Cases | -| --------------------------------- | ----- | --- | ------------------------------- | ----- | -| `thinking.test.ts` | 71 | | `goUsageTrackerWindows.test.ts` | 17 | -| `retry.test.ts` | 37 | | `responsesRequest.test.ts` | 16 | -| `goUsageTracker.test.ts` | 35 | | `registry.test.ts` | 16 | -| `metadata.test.ts` | 28 | | `routing.test.ts` | 15 | -| `utils.test.ts` | 22 | | `messages.test.ts` | 19 | -| `autocomplete.test.ts` | 21 | | `extractors.test.ts` | 15 | -| `toolCallAccumulator.test.ts` | 19 | | `config.test.ts` | 15 | -| `visionProxy.test.ts` | 17 | | `openai-request.test.ts` | 12 | -| `goUsageSync.test.ts` | 10 | | `usageProfile.test.ts` | 9 | -| `sse.test.ts` | 9 | | `reasoningHistory.test.ts` | 9 | -| `autocompleteUsage.test.ts` | 9 | | `deprecatedFilter.test.ts` | 8 | -| `schema.test.ts` | 7 | | `modelLimits.test.ts` | 5 | -| `imageNormalizer.test.ts` | 5 | | `session-header.test.ts` | 4 | -| `issue216-217-regression.test.ts` | 4 | | `providerEnablement.test.ts` | 3 | -| `deferredToolImages.test.ts` | 3 | | `modelCapabilities.test.ts` | 3 | -| `modelNames.test.ts` | 3 | | `apiKeyResolution.test.ts` | 3 | -| `chatParts.test.ts` | 3 | | `engine.test.ts` | 3 | -| `tokenEstimate.test.ts` | 2 | | `headers.test.ts` | 2 | -| | | | `agentProvider.test.ts` | 1 | +### Unit tests (`src/test/` — 40 files, 532 cases) + +Pure/domain modules get co-located tests. **Harness:** Node's built-in `node --test` runner via `scripts/run-unit-tests.ts`, which collects the compiled `out/test/*.test.js` files; tests import from compiled `out/` modules with explicit `.js` extensions (Node16/ESM-style) and use `node:test` + `node:assert/strict`. Tests never need a live model — they cover the deterministic parts (message conversion, chunk parsing, token estimation, routing). Per-file case counts (532 total, audited 2026-10-13): + +| Test file | Cases | | Test file | Cases | +| -------------------------------------- | ----- | --- | ------------------------------- | ----- | +| `thinking.test.ts` | 88 | | `goUsageTrackerWindows.test.ts` | 17 | +| `retry.test.ts` | 37 | | `responsesRequest.test.ts` | 18 | +| `goUsageTracker.test.ts` | 35 | | `registry.test.ts` | 16 | +| `metadata.test.ts` | 28 | | `routing.test.ts` | 18 | +| `utils.test.ts` | 22 | | `messages.test.ts` | 19 | +| `autocomplete.test.ts` | 21 | | `extractors.test.ts` | 19 | +| `toolCallAccumulator.test.ts` | 21 | | `config.test.ts` | 16 | +| `visionProxy.test.ts` | 17 | | `thinkTags.test.ts` | 13 | +| `goUsageSync.test.ts` | 10 | | `openai-request.test.ts` | 12 | +| `sse.test.ts` | 9 | | `reasoningHistory.test.ts` | 9 | +| `autocompleteUsage.test.ts` | 9 | | `deprecatedFilter.test.ts` | 8 | +| `schema.test.ts` | 7 | | `modelLimits.test.ts` | 5 | +| `issue244-followup-regression.test.ts` | 6 | | `errors.test.ts` | 4 | +| `imageNormalizer.test.ts` | 5 | | `session-header.test.ts` | 4 | +| `issue216-217-regression.test.ts` | 4 | | `providerEnablement.test.ts` | 3 | +| `deferredToolImages.test.ts` | 3 | | `modelCapabilities.test.ts` | 3 | +| `modelNames.test.ts` | 3 | | `apiKeyResolution.test.ts` | 3 | +| `chatParts.test.ts` | 3 | | `engine.test.ts` | 3 | +| `tokenEstimate.test.ts` | 2 | | `usageProfile.test.ts` | 9 | +| `headers.test.ts` | 2 | | `agentProvider.test.ts` | 1 | ### Scripts (`scripts/`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72bd92985..c6c5280f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente - **`[Thinking]` The per-model Reasoning Effort picker now actually works for models with no known thinking family (#252).** For any model not matched by a hardcoded family in `core/registry.ts` (`space-bunny-free`, `big-pickle`, `hy3`, …) the picker rendered a Thinking Effort control whose selection was silently discarded — the request never carried a reasoning field, so the model always answered at its default effort. Two independent defects: `FallbackThinking` had no payload path at all (`buildPayload` returned `{}`), _and_ all four `buildPayload` call sites called `thinkingProviderFor(modelId)` without metadata, so any metadata-driven gate could never fire. models.dev is now the source of truth: `metadata.reasoning` decides whether a model gets a control, its own `reasoning_options` decide which levels (falling back to a shared `off`/`low`/`medium`/`high`/`xhigh`/`max` ladder), and the pick is sent as a plain OpenAI-compatible `reasoning_effort`. A new `opencodego.thinking.generic` setting provides a workspace-wide default. Family-less models routed to non-chat endpoints (`claude-*` → Anthropic messages, `gemini-*` → Google, `grok-*` → Responses) are deliberately excluded — `reasoning_effort` is meaningless on those wire formats — and the `big-pickle` repetition penalty (issue #207) is preserved and composes with the effort field. Fixing this exposed a second bug in the same feature: `prepareChatRequest` resolved thinking config _before_ metadata was available and without passing it, so the generic strategy dropped the per-model pick outright while the workspace setting kept working — a confusing split where the Copilot dropdown did nothing but settings worked. Metadata is now a required field on `ResolveThinkingConfigInput` so the compiler rejects that call shape. Documented in `docs/issues/109-20261004-issue252-generic-reasoning-effort.md`. +- **`[Streaming]` Text preceding tool calls is no longer truncated by 8 characters.** Reasoning models streaming tool calls (such as MiniMax Mimo or DeepSeek via OpenCode Go) had their trailing text chunk split across the tool card widget in VS Code Copilot Chat. Root cause was twofold: `ThinkTagFilter` unconditionally sliced off the last 8 characters into `this.carry` even on ordinary text ending without any `<` character; and `OpenAiResponseExtractor` flushed tool calls on `finish_reason: "tool_calls"` without first flushing `this.thinkFilter.finish()`. Fix: `ThinkTagFilter.process()` now only carries over characters if the buffer tail starts with a prefix of `` (inside think block) or `` (outside think block); and both OpenAI and Anthropic extractors flush any pending carry as visible text before emitting tool call parts. Documented in `docs/issues/111-20261013-streaming-text-truncation-tool-calls.md`. + ## [0.7.8] — 2026-09-25 ### Fixed diff --git a/docs/issues/111-20261013-streaming-text-truncation-tool-calls.md b/docs/issues/111-20261013-streaming-text-truncation-tool-calls.md new file mode 100644 index 000000000..2686e16ab --- /dev/null +++ b/docs/issues/111-20261013-streaming-text-truncation-tool-calls.md @@ -0,0 +1,32 @@ +# Fix Streaming Text Truncation Before Tool Calls in ThinkTagFilter and Extractors + +**Status:** ✅ Solved +**Topic:** streaming / think-tags / tool-calls / truncation +**Updated:** 2026-10-13 +**Tags:** #streaming #think-tags #tool-calls #truncation #extractors +**Related:** docs [21 — minimax-m3-think-tag-leak-reimplementation](21-20260613-minimax-m3-think-tag-leak-reimplementation.md), [50 — pr100-tool-call-flush-review-merge](50-20260803-pr100-tool-call-flush-review-merge.md), [72 — pr164-force-think-tag-stripping-subagents](72-20260821-pr164-force-think-tag-stripping-subagents.md) + +--- + +## Problem + +When streaming tool calls with reasoning models (e.g. MiniMax Mimo, DeepSeek, or other models via OpenCode Go), text preceding a tool call was truncated by exactly 8 characters (e.g., `"follow up with a "` -> `[tool invocation]` -> `"summary."`). In VS Code's Copilot Agent Host, this caused sentences to be split across the tool card widget because the trailing 8 characters were emitted as a new markdown block after the tool call started. + +## Root Cause + +1. In `src/transports/thinkTags.ts`, `ThinkTagFilter.process()` unconditionally sliced off the last `maxScan` (8) characters of the buffer into `this.carry` across every text chunk (because `OPEN_THINK_TAG` is 7 chars and `CLOSE_THINK_TAG` is 8 chars), even when the buffer ended in ordinary prose with no `<` character. +2. In `src/transports/extractors.ts`, `OpenAiResponseExtractor` flushed accumulated `LanguageModelToolCallPart`s upon receiving `finish_reason: "tool_calls"` without first flushing `this.thinkFilter.finish()`. This stranded any carry in `thinkFilter` until the end-of-stream `finally` block, emitting it _after_ the tool call. + +## Changes + +| File | Change | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/transports/thinkTags.ts` | In `ThinkTagFilter.process()`, eliminated unconditional 8-character tail buffering. Inside `` blocks without a closing tag, only buffer into `this.carry` if `lastLt >= pos` and `CLOSE_THINK_TAG.startsWith(buffer.slice(lastLt))`. Outside `` blocks without an opening tag, only buffer into `this.carry` if `lastLt >= pos` and `OPEN_THINK_TAG.startsWith(buffer.slice(lastLt))`. Otherwise, append all characters directly and clear `this.carry`. `finish()` continues to flush remaining carry. | +| `src/transports/extractors.ts` | In `BaseResponseExtractor`, introduced shared `flushThinkFilterToParts` and `flushThinkFilterToProgress` helpers to cleanly flush think filter carry. In `OpenAiResponseExtractor.extractStreamParts()`, flush think filter carry before flushing tool calls when `shouldFlushOnFinishReason` fires. In `flushRemainingToolCalls()`, flush think filter carry before flushing remaining tool calls. In `AnthropicResponseExtractor`, flush think filter carry before emitting tool calls on `message_delta` and `message_stop`. | +| `src/test/thinkTags.test.ts` | Added unit tests covering ordinary prose preservation (e.g. "summary."), boundary tag matching across chunks, non-tag `<` comparisons, and unclosed tag flushing. | +| `src/test/extractors.test.ts` | Added tests verifying think filter carry is flushed as visible text before tool call parts in both OpenAI and Anthropic extractors. | + +## Verification + +- `npm run lint` passes all 7 checks (Editorconfig, ESLint, Markdown, Prettier, Shell, TypeScript, Tests). +- Unit tests verify that text is emitted without delay or truncation before tool invocations. diff --git a/src/test/extractors.test.ts b/src/test/extractors.test.ts index e0e4247c7..40af54e46 100644 --- a/src/test/extractors.test.ts +++ b/src/test/extractors.test.ts @@ -38,12 +38,17 @@ moduleResolver._resolveFilename = function (request: string, parent: unknown, .. }; let OpenAiResponseExtractor: typeof import("../transports/extractors.js").OpenAiResponseExtractor; +let AnthropicResponseExtractor: typeof import("../transports/extractors.js").AnthropicResponseExtractor; +let ThinkTagFilter: typeof import("../transports/thinkTags.js").ThinkTagFilter; let truncateToolName: typeof import("../request/openai.js").truncateToolName; describe("OpenAiResponseExtractor — truncated tool name round-trip", () => { before(async () => { const extractors = await import("../transports/extractors.js"); OpenAiResponseExtractor = extractors.OpenAiResponseExtractor; + AnthropicResponseExtractor = extractors.AnthropicResponseExtractor; + const thinkTags = await import("../transports/thinkTags.js"); + ThinkTagFilter = thinkTags.ThinkTagFilter; const openai = await import("../request/openai.js"); truncateToolName = openai.truncateToolName; }); @@ -299,3 +304,140 @@ describe("updateRequestUsageSummary — Responses nested usage", () => { assert.equal(summary.totalTokens, 120); }); }); + +describe("OpenAiResponseExtractor — thinkTag carry flushing before tool calls", () => { + it("emits pending thinkFilter carry as TextPart before tool calls on finish_reason: 'tool_calls'", () => { + const filter = new ThinkTagFilter(); + const extractor = new OpenAiResponseExtractor(undefined, undefined, filter); + + // Delta with text ending in a partial tag ' { + const filter = new ThinkTagFilter(); + const extractor = new OpenAiResponseExtractor(undefined, undefined, filter); + + extractor.extractStreamParts({ + choices: [ + { + index: 0, + delta: { + content: "Run command <", + tool_calls: [{ index: 0, id: "call_2", type: "function", function: { name: "run_cmd", arguments: '{"cmd":"ls"}' } }], + }, + finish_reason: null, + }, + ], + }); + + const emitted: { value?: string; callId?: string; name?: string }[] = []; + const progress: import("vscode").Progress = { + report: (part) => { + emitted.push(part as unknown as { value?: string; callId?: string; name?: string }); + }, + }; + + extractor.flushRemainingToolCalls(progress, undefined); + + assert.equal(emitted.length, 2); + assert.equal(emitted[0].value, "<"); + assert.equal(emitted[1].callId, "call_2"); + assert.equal(emitted[1].name, "run_cmd"); + }); +}); + +describe("AnthropicResponseExtractor — thinkTag carry flushing before tool calls", () => { + it("flushes pending thinkFilter carry before emitting tool calls on message_delta", () => { + const filter = new ThinkTagFilter(); + const extractor = new AnthropicResponseExtractor(undefined, undefined, filter); + + // Initial content block start with tool_use + extractor.extractStreamParts({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "anthropic_call_1", name: "search" }, + }); + // Text block delta that leaves carry + extractor.extractStreamParts({ + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "Searching { + const filter = new ThinkTagFilter(); + const extractor = new AnthropicResponseExtractor(undefined, undefined, filter); + + extractor.extractStreamParts({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "anthropic_call_2", name: "calculate" }, + }); + extractor.extractStreamParts({ + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "Result: 5 <" }, + }); + extractor.extractStreamParts({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"x":5}' }, + }); + + // message_stop without prior message_delta stop_reason + const parts = extractor.extractStreamParts({ + type: "message_stop", + }) as { value?: string; callId?: string; name?: string }[]; + + assert.equal(parts.length, 2); + assert.equal(parts[0].value, "<"); + assert.equal(parts[1].callId, "anthropic_call_2"); + assert.equal(parts[1].name, "calculate"); + }); +}); diff --git a/src/test/thinkTags.test.ts b/src/test/thinkTags.test.ts new file mode 100644 index 000000000..156b061f1 --- /dev/null +++ b/src/test/thinkTags.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ThinkTagFilter, shouldStripThinkTags, createThinkTagFilter } from "../transports/thinkTags.js"; + +describe("ThinkTagFilter — text preservation and boundary buffering", () => { + it("does not buffer or delay ordinary text ending in words like 'summary.'", () => { + const filter = new ThinkTagFilter(); + const result = filter.process("follow up with a summary."); + assert.equal(result.visible, "follow up with a summary."); + assert.equal(result.thinking, ""); + + const finish = filter.finish(); + assert.equal(finish.visible, ""); + assert.equal(finish.thinking, ""); + }); + + it("does not buffer ordinary text with length smaller than max tag length", () => { + const filter = new ThinkTagFilter(); + const result = filter.process("hello"); + assert.equal(result.visible, "hello"); + assert.equal(result.thinking, ""); + }); + + it("does not buffer text containing non-tag '<' comparison operators", () => { + const filter = new ThinkTagFilter(); + const result = filter.process("is 5 < 10 in mathematics?"); + assert.equal(result.visible, "is 5 < 10 in mathematics?"); + assert.equal(result.thinking, ""); + }); + + it("buffers a partial opening tag at chunk boundary and resolves it in next chunk", () => { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("here is the result internal thoughtsdone."); + assert.equal(chunk2.visible, "done."); + assert.equal(chunk2.thinking, "internal thoughts"); + }); + + it("buffers a single '<' at chunk boundary and resolves as normal text when not a think tag", () => { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("5 <"); + assert.equal(chunk1.visible, "5 "); + assert.equal(chunk1.thinking, ""); + + const chunk2 = filter.process(" 10 is true."); + assert.equal(chunk2.visible, "< 10 is true."); + assert.equal(chunk2.thinking, ""); + }); + + it("buffers a partial closing tag at chunk boundary inside a think block", () => { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("calculating the answer The answer is 42."); + assert.equal(chunk2.visible, "The answer is 42."); + assert.equal(chunk2.thinking, ""); + }); + + it("does not buffer non-tag '<' inside a think block", () => { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("checking if 3 < 5 then proceedDone"); + assert.equal(chunk1.visible, "Done"); + assert.equal(chunk1.thinking, "checking if 3 < 5 then proceed"); + }); + + it("flushes partial tag carry on finish() when stream ends outside think block", () => { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("incomplete tag { + const filter = new ThinkTagFilter(); + const chunk1 = filter.process("still thinking { + const filter = new ThinkTagFilter(); + filter.process("test { + it("strips for minimax-m models in auto mode", () => { + assert.equal(shouldStripThinkTags("auto", "minimax-m2.5"), true); + assert.equal(shouldStripThinkTags("auto", "minimax-m3"), true); + assert.equal(shouldStripThinkTags("auto", "deepseek-chat"), false); + }); + + it("respects always and never modes", () => { + assert.equal(shouldStripThinkTags("always", "deepseek-chat"), true); + assert.equal(shouldStripThinkTags("never", "minimax-m2.5"), false); + assert.equal(shouldStripThinkTags(undefined, "minimax-m2.5"), false); + }); + + it("creates filter instance when enabled or force-overridden", () => { + assert.ok(createThinkTagFilter("always", "any-model") instanceof ThinkTagFilter); + assert.equal(createThinkTagFilter("never", "minimax-m2.5"), undefined); + assert.ok(createThinkTagFilter("never", "minimax-m2.5", true) instanceof ThinkTagFilter); + }); +}); diff --git a/src/transports/extractors.ts b/src/transports/extractors.ts index b8a501694..e547a1af9 100644 --- a/src/transports/extractors.ts +++ b/src/transports/extractors.ts @@ -71,20 +71,40 @@ abstract class BaseResponseExtractor { return reasoning; } + /** Flush any buffered carry in the think filter to the returned parts array. */ + protected flushThinkFilterToParts(parts: vscode.LanguageModelResponsePart[]): void { + if (!this.thinkFilter) { + return; + } + const { visible, thinking } = this.thinkFilter.finish(); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + } + + /** Flush any buffered carry in the think filter to the progress sink. */ + protected flushThinkFilterToProgress(progress: vscode.Progress, localRequestId?: string): void { + if (!this.thinkFilter) { + return; + } + const { visible, thinking } = this.thinkFilter.finish(); + if (visible) { + this.emittedTextLength += visible.length; + reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + } + /** Emit the shared end-of-stream reasoning fallback. */ flushReasoningFallback(progress: vscode.Progress, localRequestId?: string): void { // Flush any remaining text in the think filter - if (this.thinkFilter) { - const { visible, thinking } = this.thinkFilter.finish(); - if (visible) { - this.emittedTextLength += visible.length; - reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - // Surface remaining think-filter carry through the thinking part channel. - this.handleReasoning(thinking); - } - } + this.flushThinkFilterToProgress(progress, localRequestId); const reasoning = this.reasoningContent.trim(); if (!reasoning) { @@ -339,6 +359,7 @@ class OpenAiResponseExtractor extends BaseResponseExtractor { // gpt-5.6-luna, issue #93) are flushed once at end-of-stream via // `flushRemainingToolCalls()`. if (ToolCallAccumulator.shouldFlushOnFinishReason(first.finish_reason)) { + this.flushThinkFilterToParts(parts); const toolParts = this.flushToolCalls(); this.emittedToolCallsCount += toolParts.length; parts.push(...toolParts); @@ -411,6 +432,7 @@ class OpenAiResponseExtractor extends BaseResponseExtractor { this.toolCallAccumulator.flush(); return; } + this.flushThinkFilterToProgress(progress, localRequestId); const toolParts = this.flushToolCalls(); this.emittedToolCallsCount += toolParts.length; for (const part of toolParts) { @@ -504,18 +526,14 @@ class AnthropicResponseExtractor extends BaseResponseExtractor { // updates the real RequestUsageSummary); here we only flush tool calls. if (eventType === "message_delta" && delta) { if (delta.stop_reason) { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); + this.flushPendingToolCalls(parts); } return parts; } // 4. message_stop: final event, flush any remaining tool calls. if (eventType === "message_stop") { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); + this.flushPendingToolCalls(parts); return parts; } @@ -569,15 +587,20 @@ class AnthropicResponseExtractor extends BaseResponseExtractor { } if (delta.stop_reason) { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); + this.flushPendingToolCalls(parts); } } return parts; } + private flushPendingToolCalls(parts: vscode.LanguageModelResponsePart[]): void { + this.flushThinkFilterToParts(parts); + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + parts.push(...toolParts); + } + private flushToolCalls(): vscode.LanguageModelToolCallPart[] { const toolCalls = Array.from(this.pendingToolCalls.values()).filter((toolCall) => toolCall.name); const parts = toolCalls.map( diff --git a/src/transports/thinkTags.ts b/src/transports/thinkTags.ts index 195f4b954..0286ba9e4 100644 --- a/src/transports/thinkTags.ts +++ b/src/transports/thinkTags.ts @@ -65,22 +65,21 @@ export class ThinkTagFilter { let visible = ""; let thinking = ""; let pos = 0; - const maxScan = Math.max(OPEN_THINK_TAG.length, CLOSE_THINK_TAG.length); while (pos < buffer.length) { if (this.insideThink) { // Look for closing const closeIdx = buffer.indexOf(CLOSE_THINK_TAG, pos); if (closeIdx === -1) { - // No closing tag found — consume the rest, but keep a tail for - // boundary matching in the next chunk. - const safeEnd = buffer.length - maxScan; - if (safeEnd > pos) { - thinking += buffer.slice(pos, safeEnd); - this.carry = buffer.slice(safeEnd); + // No closing tag found — consume the rest, but keep a tail if it could + // be the start of a closing tag split across chunk boundaries. + const lastLt = buffer.lastIndexOf("<"); + if (lastLt >= pos && CLOSE_THINK_TAG.startsWith(buffer.slice(lastLt))) { + thinking += buffer.slice(pos, lastLt); + this.carry = buffer.slice(lastLt); } else { - // Entire remaining buffer is shorter than max scan — carry it all - this.carry = buffer.slice(pos); + thinking += buffer.slice(pos); + this.carry = ""; } break; } @@ -99,13 +98,15 @@ export class ThinkTagFilter { // Look for opening const openIdx = buffer.indexOf(OPEN_THINK_TAG, pos); if (openIdx === -1) { - // No opening tag — emit visible text but keep a tail for boundary - const safeEnd = buffer.length - maxScan; - if (safeEnd > pos) { - visible += buffer.slice(pos, safeEnd); - this.carry = buffer.slice(safeEnd); + // No opening tag — emit visible text but keep a tail if it could + // be the start of an opening tag split across chunk boundaries. + const lastLt = buffer.lastIndexOf("<"); + if (lastLt >= pos && OPEN_THINK_TAG.startsWith(buffer.slice(lastLt))) { + visible += buffer.slice(pos, lastLt); + this.carry = buffer.slice(lastLt); } else { - this.carry = buffer.slice(pos); + visible += buffer.slice(pos); + this.carry = ""; } break; }