Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions ARCHITECTURE-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</think>` (inside think block) or `<think>` (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
Expand Down
32 changes: 32 additions & 0 deletions docs/issues/111-20261013-streaming-text-truncation-tool-calls.md
Original file line number Diff line number Diff line change
@@ -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 `<think>` blocks without a closing tag, only buffer into `this.carry` if `lastLt >= pos` and `CLOSE_THINK_TAG.startsWith(buffer.slice(lastLt))`. Outside `<think>` 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.
142 changes: 142 additions & 0 deletions src/test/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 '<thi'
const parts1 = extractor.extractStreamParts({
choices: [
{
index: 0,
delta: {
content: "Calling tool <thi",
tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "test_tool", arguments: '{"a":1}' } }],
},
finish_reason: null,
},
],
}) as { value?: string }[];

// First chunk emits the safe portion
assert.equal(parts1.length, 1);
assert.equal(parts1[0].value, "Calling tool ");

// Second chunk triggers finish_reason: "tool_calls"
const parts2 = extractor.extractStreamParts({
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
}) as { value?: string; callId?: string; name?: string }[];

// Second chunk must emit '<thi' as TextPart BEFORE the ToolCallPart!
assert.equal(parts2.length, 2);
assert.equal(parts2[0].value, "<thi");
assert.equal(parts2[1].callId, "call_1");
assert.equal(parts2[1].name, "test_tool");
assert.equal(extractor.emittedText, "Calling tool <thi".length);
});

it("flushRemainingToolCalls flushes pending thinkFilter carry before reporting tool calls", () => {
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<import("vscode").LanguageModelResponsePart2> = {
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 <thi" },
});
// Tool argument delta
extractor.extractStreamParts({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"q":"test"}' },
});

// message_delta with stop_reason
const parts = extractor.extractStreamParts({
type: "message_delta",
delta: { stop_reason: "tool_use" },
}) as { value?: string; callId?: string; name?: string }[];

assert.equal(parts.length, 2);
assert.equal(parts[0].value, "<thi");
assert.equal(parts[1].callId, "anthropic_call_1");
assert.equal(parts[1].name, "search");
});

it("flushes pending thinkFilter carry on message_stop", () => {
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");
});
});
Loading
Loading