summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 06:54:48 +0900
committerAdam Malczewski <[email protected]>2026-05-28 06:54:48 +0900
commit8b17d929e70a43749fd962554214bf8ba3e9380f (patch)
treebdff1f409a8fe78850044c23b38436d84cbbcca9
parent25b6aac6d4df02e29a2ad4333272bb0998ecd410 (diff)
downloaddispatch-8b17d929e70a43749fd962554214bf8ba3e9380f.tar.gz
dispatch-8b17d929e70a43749fd962554214bf8ba3e9380f.zip
refactor(core): upgrade ai-sdk v4 → v6 + Anthropic/openai-compatible reasoning round-trip + max-thinking budget audit
Migrates the LLM stack from [email protected] + @ai-sdk/[email protected] + @ai-sdk/[email protected] to [email protected] + @ai-sdk/[email protected] + @ai-sdk/[email protected]. Full design in plan-v6-upgrade.md; two rounds of Gemini code review captured in report.md. Motivation: the recurring 'reasoning-signature without reasoning' error on Claude Opus 4.7 was a v4 SDK artefact — @ai-sdk/[email protected] emitted Anthropic signature_delta as a separate stream chunk that orphaned when the model produced a signed-but-empty thinking block, and our chunk store had no signature field so the round-trip back to Anthropic was rejected on the next turn. In v6, signatures arrive inside providerMetadata on the reasoning-end event, and the orphan-signature class of bug is gone at the SDK level. Core changes: • ThinkingChunk gains optional metadata?: Record<string, unknown> (the v6 providerMetadata blob). A non-undefined metadata 'seals' the chunk: subsequent reasoning-delta opens a new chunk rather than extending the sealed one. • AgentEvent gains { type: 'reasoning-end'; metadata? } (replaces the v4 reasoning-signature variant). • toModelMessages (replaces toCoreMessages): - returns ModelMessage[] (was CoreMessage[]) - thinking → { type: 'reasoning', text, providerOptions: metadata } - tool-batch entries → { type: 'tool-call', input } (was 'args') - tool results → { output: { type: 'text', value } } ToolResultOutput • Claude OAuth uses createAnthropic({ authToken }) natively — no more custom-fetch x-api-key → Bearer swap. • rewriteBodyForOpus47 deleted — Opus 4.7 adaptive thinking is native via providerOptions.anthropic.thinking = { type: 'adaptive' }. • V1 middleware → V3 (specificationVersion: 'v3'). • v4-era normalizeMessages openai-compatible middleware deleted; the v6 openai-compatible provider extracts reasoning_content natively from { type: 'reasoning' } content parts. • applyAnthropicStructuralNormalisations (mirrors opencode provider/transform.ts:53-148): drops empty text/reasoning parts, scrubs non-[a-zA-Z0-9_-] toolCallIds, splits [tool-call, non-tool] assistant turns (Anthropic rejects tool_use followed by text). • applyOpenAICompatibleReasoningNormalisation (mirrors opencode transform.ts:217-249): lifts reasoning text into providerOptions.openaiCompatible.reasoning_content (always, even empty). Solves DeepSeek 'The reasoning_content in the thinking mode must be passed back' — the v6 SDK skips emitting reasoning_content when text is empty (dist/index.mjs:245), but DeepSeek requires the field present once thinking was used. • Tools: tool({ inputSchema: jsonSchema(zodToJsonSchema(...)) }) (was parameters: ZodSchema). AI SDK tools have no execute callback — the agent runs tools manually for permission prompts and shell-output streaming. New dep: zod-to-json-schema@^3.25.2. • fullStream event loop rewritten for v6 event shape: text-delta (text not textDelta), reasoning-start/delta/end, tool-input-*, tool-call (input not args), tool-result, tool-error (new), abort (new), start-step/finish-step, finish. Max-thinking audit (matches opencode transform.ts:642-671 budgets): • Claude enabled-thinking max budget 16000 → 31999 (Anthropic ceiling) • Claude enabled-thinking high budget 10000 → 16000 • maxOutputTokens 'budget + 8000' → fixed 32000 (matches opencode's OUTPUT_TOKEN_MAX; model self-allocates thinking vs response within) • Opus 4.7 adaptive thinking gains display: 'summarized' and sibling effort field (without these, thinking content is hidden by Anthropic and the model barely thinks). Frontend mirrors: • types.ts — ThinkingChunk.metadata?, AgentEvent reasoning-end • tabs.svelte.ts — routes reasoning-end through applyChunkEvent • ChatMessage.svelte — hides empty thinking chunks; hides the entire assistant bubble when no chunk has renderable content Gemini-review-driven fixes: • tool-error and abort stream events now surface as error chunks (were silently ignored) • toolCallId scrubbing pass (opencode transform.ts:96-122 parity) • Empty-reasoning-cull explicit test coverage for both Anthropic structural normalisation and DeepSeek path Test counts (223 tests across 3 packages, all green): • tests/chunks/append.test.ts: 44 (was 38) — reasoning-end sealing, orphan walk-back, multi-block interleaving • tests/agent/agent.test.ts: 24 (was 5) — exhaustive v6 event mappings, structural normalisations, signature/reasoning_content round-trip, tool-error/abort branches, DeepSeek scenario, empty reasoning edge case • tests/llm/provider.test.ts: 9 (was 22) — dropped 13 obsolete v4 middleware tests; new minimal tests confirm no middleware wrapping on default openai-compat path and that createAnthropic gets authToken vs apiKey correctly for OAuth vs api-key flows • tests/tools/registry.test.ts: 10 (was 4) — v6 tool() contract (inputSchema, no execute, JSON Schema for nested zod) • packages/api/tests/agent-manager.test.ts: 12 (was 7) — mock Agent emits v6 reasoning events; reasoning-end broadcast + ordering • packages/frontend/tests/chat-store.test.ts: 35 (was 32) — reasoning-end flow through Svelte $state store typecheck clean (tsc --noEmit on core + api, svelte-check on frontend), biome clean across 124 files.
-rw-r--r--bun.lock47
-rw-r--r--packages/api/tests/agent-manager.test.ts113
-rw-r--r--packages/core/package.json9
-rw-r--r--packages/core/src/agent/agent.ts370
-rw-r--r--packages/core/src/chunks/append.ts66
-rw-r--r--packages/core/src/llm/provider.ts151
-rw-r--r--packages/core/src/tools/registry.ts48
-rw-r--r--packages/core/src/types/index.ts24
-rw-r--r--packages/core/tests/agent/agent.test.ts841
-rw-r--r--packages/core/tests/chunks/append.test.ts97
-rw-r--r--packages/core/tests/llm/provider.test.ts392
-rw-r--r--packages/core/tests/tools/registry.test.ts109
-rw-r--r--packages/frontend/serve.ts6
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte58
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts1
-rw-r--r--packages/frontend/src/lib/types.ts9
-rw-r--r--packages/frontend/tests/chat-store.test.ts56
-rw-r--r--plan-v6-upgrade.md450
-rw-r--r--report.md36
19 files changed, 2323 insertions, 560 deletions
diff --git a/bun.lock b/bun.lock
index 9518892..2573c18 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,14 +25,15 @@
"name": "@dispatch/core",
"version": "0.0.1",
"dependencies": {
- "@ai-sdk/anthropic": "^1.2.12",
- "@ai-sdk/openai-compatible": "^0.2.0",
- "ai": "^4.0.0",
+ "@ai-sdk/anthropic": "^3.0.79",
+ "@ai-sdk/openai-compatible": "^2.0.48",
+ "ai": "^6.0.191",
"chokidar": "^5.0.0",
"smol-toml": "^1.6.1",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.26.8",
"zod": "^3.23.0",
+ "zod-to-json-schema": "^3.25.2",
},
"devDependencies": {
"@types/bun": "latest",
@@ -65,17 +66,15 @@
"packages": {
"7zip-bin": ["[email protected]", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
- "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
+ "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-saEX+h5JDOkT9P/+REKDyikbnJiToFuLipgNcsmu4Zr3GW5kW1m9HhvrPK+vj63itIOsoZU6tmVIjkrePOlIUA=="],
- "@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-LkvfcM8slJedRyJa/MiMiaOzcMjV1zNDwzTHEGz7aAsgsQV0maLfmJRi/nuSwf5jmp0EouC+JXXDUj2l94HgQw=="],
+ "@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q=="],
- "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+ "@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="],
- "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+ "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
- "@ai-sdk/react": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
-
- "@ai-sdk/ui-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
+ "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
@@ -241,6 +240,8 @@
"@sindresorhus/is": ["@sindresorhus/[email protected]", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
+ "@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
@@ -289,8 +290,6 @@
"@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
- "@types/diff-match-patch": ["@types/[email protected]", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
-
"@types/dompurify": ["@types/[email protected]", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -315,6 +314,8 @@
"@types/yauzl": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
+ "@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
+
"@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -337,7 +338,7 @@
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
- "ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q=="],
+ "ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "3.0.120", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ=="],
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -459,16 +460,12 @@
"delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
- "dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
-
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["[email protected]", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
- "diff-match-patch": ["[email protected]", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
-
"dir-compare": ["[email protected]", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"dmg-builder": ["[email protected]", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
@@ -529,6 +526,8 @@
"estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+ "eventsource-parser": ["[email protected]", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
+
"expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exponential-backoff": ["[email protected]", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -637,8 +636,6 @@
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
- "jsondiffpatch": ["[email protected]", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
-
"jsonfile": ["[email protected]", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -773,8 +770,6 @@
"quick-lru": ["[email protected]", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
- "react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
-
"read-binary-file-arch": ["[email protected]", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
"readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@@ -803,8 +798,6 @@
"sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
- "secure-json-parse": ["[email protected]", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
-
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver-compare": ["[email protected]", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
@@ -855,8 +848,6 @@
"svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
- "swr": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
-
"tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -867,8 +858,6 @@
"temp-file": ["[email protected]", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
- "throttleit": ["[email protected]", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
-
"tiny-async-pool": ["[email protected]", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tinybench": ["[email protected]", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -903,8 +892,6 @@
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
- "use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
-
"utf8-byte-length": ["[email protected]", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
"verror": ["[email protected]", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@@ -1001,8 +988,6 @@
"iconv-corefoundation/node-addon-api": ["[email protected]", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
- "jsondiffpatch/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
-
"lru-cache/yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-abi/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 28fa044..ea8dc46 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -1,6 +1,11 @@
import type { AgentEvent, ToolDefinition } from "@dispatch/core";
import { describe, expect, it, vi } from "vitest";
+// Spy on appendEventToChunks so we can assert persistence calls
+const appendEventToChunksSpy = vi.fn((_chunks: unknown[], _event: unknown) => {
+ // no-op; we inspect calls in tests
+});
+
// Mock @dispatch/core's Agent to avoid real LLM calls
vi.mock("@dispatch/core", () => ({
Agent: class MockAgent {
@@ -9,13 +14,26 @@ vi.mock("@dispatch/core", () => ({
async *run(_message: string) {
yield { type: "status", status: "running" } as const;
await new Promise<void>((r) => setTimeout(r, 10));
+ // v6-style reasoning turn: delta(s) then end with providerMetadata
+ yield { type: "reasoning-delta", delta: "thinking about it" } as const;
+ yield {
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "mock-sig" } },
+ } as const;
yield { type: "text-delta", delta: "Hello " } as const;
yield { type: "text-delta", delta: "world" } as const;
yield {
type: "done",
message: {
role: "assistant",
- chunks: [{ type: "text", text: "Hello world" }],
+ chunks: [
+ {
+ type: "thinking",
+ text: "thinking about it",
+ metadata: { anthropic: { signature: "mock-sig" } },
+ },
+ { type: "text", text: "Hello world" },
+ ],
},
} as const;
yield { type: "status", status: "idle" } as const;
@@ -185,9 +203,7 @@ vi.mock("@dispatch/core", () => ({
getMessagesForTab() {
return [];
},
- appendEventToChunks(_chunks: unknown[], _event: unknown) {
- // no-op stub; chunk accumulation isn't exercised in these unit tests
- },
+ appendEventToChunks: appendEventToChunksSpy,
applySystemEvent(_messages: unknown[], _event: unknown) {
return { messageId: "mock-system-msg" };
},
@@ -311,4 +327,93 @@ describe("AgentManager", () => {
expect(listener1).toHaveBeenCalled();
expect(listener2).toHaveBeenCalled();
});
+
+ // ─── v6 reasoning-end tests ───────────────────────────────────────
+
+ it("reasoning-end event is broadcast to WS listeners", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("tab-reasoning", "think please");
+
+ const reasoningEndEvents = events.filter((e) => e.type === "reasoning-end");
+ expect(reasoningEndEvents.length).toBeGreaterThan(0);
+ expect(reasoningEndEvents[0]).toMatchObject({
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "mock-sig" } },
+ });
+ });
+
+ it("reasoning-end is passed to appendEventToChunks for persistence", async () => {
+ appendEventToChunksSpy.mockClear();
+ const manager = new AgentManager();
+
+ await manager.processMessage("tab-persist", "think and persist");
+
+ // Find all calls to appendEventToChunks that received a reasoning-end event
+ const reasoningEndCalls = appendEventToChunksSpy.mock.calls.filter(
+ ([_chunks, event]) => (event as AgentEvent).type === "reasoning-end",
+ );
+ expect(reasoningEndCalls.length).toBeGreaterThan(0);
+
+ // The event should carry the metadata blob
+ const [, reasoningEndEvent] = reasoningEndCalls[0] as [unknown[], AgentEvent];
+ expect(reasoningEndEvent).toMatchObject({
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "mock-sig" } },
+ });
+ });
+
+ it("reasoning-end follows reasoning-delta in broadcast order (chunk accumulator ordering)", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("tab-ordering", "think in order");
+
+ const types = events.map((e) => e.type);
+ const deltaIdx = types.indexOf("reasoning-delta");
+ const endIdx = types.indexOf("reasoning-end");
+
+ // Both must be present
+ expect(deltaIdx).toBeGreaterThanOrEqual(0);
+ expect(endIdx).toBeGreaterThanOrEqual(0);
+
+ // reasoning-end must come AFTER reasoning-delta
+ expect(endIdx).toBeGreaterThan(deltaIdx);
+
+ // reasoning-end must come BEFORE any text-delta (reasoning precedes text)
+ const textDeltaIdx = types.indexOf("text-delta");
+ if (textDeltaIdx >= 0) {
+ expect(endIdx).toBeLessThan(textDeltaIdx);
+ }
+ });
+
+ it("done event includes a thinking chunk with metadata in its message", async () => {
+ const manager = new AgentManager();
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("tab-done-chunks", "think and respond");
+
+ const doneEvent = events.find((e) => e.type === "done") as
+ | Extract<AgentEvent, { type: "done" }>
+ | undefined;
+ expect(doneEvent).toBeDefined();
+
+ const thinkingChunk = doneEvent?.message.chunks.find((c) => c.type === "thinking");
+ expect(thinkingChunk).toBeDefined();
+ expect(thinkingChunk).toMatchObject({
+ type: "thinking",
+ text: "thinking about it",
+ metadata: { anthropic: { signature: "mock-sig" } },
+ });
+ });
});
diff --git a/packages/core/package.json b/packages/core/package.json
index 6345ac3..3ca568b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -11,14 +11,15 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@ai-sdk/anthropic": "^1.2.12",
- "@ai-sdk/openai-compatible": "^0.2.0",
- "ai": "^4.0.0",
+ "@ai-sdk/anthropic": "^3.0.79",
+ "@ai-sdk/openai-compatible": "^2.0.48",
+ "ai": "^6.0.191",
"chokidar": "^5.0.0",
"smol-toml": "^1.6.1",
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.26.8",
- "zod": "^3.23.0"
+ "zod": "^3.23.0",
+ "zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/bun": "latest"
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 4e58378..7def41c 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -1,5 +1,6 @@
import { dirname } from "node:path";
-import type { CoreMessage, CoreSystemMessage } from "ai";
+import type { ProviderOptions } from "@ai-sdk/provider-utils";
+import type { ModelMessage, SystemModelMessage } from "ai";
import { streamText } from "ai";
import { appendEventToChunks } from "../chunks/append.js";
import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
@@ -20,7 +21,7 @@ import type {
} from "../types/index.js";
/**
- * Rebuild AI SDK `CoreMessage[]` from our internal `ChatMessage[]`.
+ * Rebuild AI SDK `ModelMessage[]` from our internal `ChatMessage[]`.
*
* Strip rules (see plan-chunk-refactor.md):
* - `role: "system"` messages are skipped wholesale (they're display-only
@@ -28,8 +29,11 @@ import type {
* - `error` chunks are skipped — the turn ended; the LLM doesn't need them.
* - `system` chunks are skipped — display-only notices.
* - `text` chunks → `{ type: "text", text }` parts.
- * - `thinking` chunks → `{ type: "reasoning", text }` parts (handles
- * Claude's `interleaved-thinking-2025-05-14` round-trip).
+ * - `thinking` chunks → `{ type: "reasoning", text, providerOptions? }` parts.
+ * The `providerOptions` carries the Anthropic signature blob (if present) so
+ * Anthropic can validate extended-thinking round-trips. Non-Anthropic
+ * reasoning has no metadata and is sent without providerOptions (accepted
+ * by Anthropic — it just won't verify a missing signature).
* - `tool-batch` chunks → one `{ type: "tool-call" }` part per entry
* inside the current assistant message, followed by a separate
* `{ role: "tool", content: [{ type: "tool-result", ... }] }` message
@@ -42,8 +46,8 @@ import type {
* LLM, so this case only arises mid-step (where the message hasn't been
* round-tripped to the LLM yet) and is benign.
*/
-function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreMessage[] {
- const result: CoreMessage[] = [];
+function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): ModelMessage[] {
+ const result: ModelMessage[] = [];
for (const msg of messages) {
if (msg.role === "system") continue;
@@ -62,8 +66,8 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
// role === "assistant"
const parts: Array<
| { type: "text"; text: string }
- | { type: "reasoning"; text: string }
- | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }
+ | { type: "reasoning"; text: string; providerOptions?: ProviderOptions }
+ | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
> = [];
const trailingToolResults: Array<{
toolCallId: string;
@@ -77,16 +81,26 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
parts.push({ type: "text", text: chunk.text });
break;
case "thinking":
- parts.push({ type: "reasoning", text: chunk.text });
+ // v6: carry providerOptions (Anthropic signature blob) if present.
+ // Non-Anthropic reasoning has no metadata → send without providerOptions
+ // (Anthropic accepts it; the round-trip just won't carry a signature).
+ parts.push({
+ type: "reasoning",
+ text: chunk.text,
+ ...(chunk.metadata !== undefined
+ ? { providerOptions: chunk.metadata as ProviderOptions }
+ : {}),
+ });
break;
case "tool-batch":
for (const entry of chunk.calls) {
const toolName = useToolPrefix ? prefixToolName(entry.name) : entry.name;
+ // v6: `input` replaces v4's `args`
parts.push({
type: "tool-call",
toolCallId: entry.id,
toolName,
- args: entry.arguments,
+ input: entry.arguments,
});
if (entry.result !== undefined) {
trailingToolResults.push({
@@ -114,6 +128,7 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
}
for (const tr of trailingToolResults) {
+ // v6: `output` (ToolResultOutput) replaces v4's `result` (raw string)
result.push({
role: "tool",
content: [
@@ -121,7 +136,7 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
type: "tool-result",
toolCallId: tr.toolCallId,
toolName: tr.toolName,
- result: tr.result,
+ output: { type: "text" as const, value: tr.result },
},
],
});
@@ -131,6 +146,163 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
}
/**
+ * Apply OpenAI-compatible reasoning normalisation.
+ *
+ * Cribbed from opencode `provider/transform.ts:217-249`. Solves DeepSeek's
+ * "The reasoning_content in the thinking mode must be passed back to the
+ * API" error.
+ *
+ * The v6 `@ai-sdk/[email protected]` provider extracts `reasoning_content`
+ * from assistant `{ type: "reasoning", text }` parts natively
+ * (see `node_modules/@ai-sdk/openai-compatible/dist/index.mjs:215-216` and :245).
+ * But that native path SKIPS emission when `reasoning.length === 0` —
+ * "reasoning_content" is omitted from the wire. DeepSeek (and similar
+ * "thinking mode" providers) require the field to be present in every
+ * follow-up turn once it was emitted at least once, even if the value is
+ * the empty string.
+ *
+ * Strategy: for every assistant message that has any `reasoning` parts,
+ * concatenate their text into `providerOptions.openaiCompatible.reasoning_content`
+ * (preserving empty strings) AND strip those parts from content. The
+ * resulting payload has a single source of truth for `reasoning_content`
+ * coming via the message-level `providerOptions` spread at line 247 of the
+ * SDK provider, which fires regardless of empty-vs-non-empty text.
+ *
+ * Only applied for the default (non-Anthropic) openai-compatible path.
+ */
+function applyOpenAICompatibleReasoningNormalisation(msgs: ModelMessage[]): ModelMessage[] {
+ return msgs.map((msg) => {
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg;
+
+ // Find reasoning parts. If there are none, this message never had
+ // a thinking turn — DeepSeek doesn't require `reasoning_content`
+ // in that case, so we pass through unchanged.
+ const reasoningParts = msg.content.filter(
+ (p): p is Extract<typeof p, { type: "reasoning" }> => p.type === "reasoning",
+ );
+ if (reasoningParts.length === 0) return msg;
+
+ const reasoningText = reasoningParts.map((p) => p.text).join("");
+ const filteredContent = msg.content.filter((p) => p.type !== "reasoning");
+
+ const existingOpts = msg.providerOptions ?? {};
+ const existingCompat = (existingOpts.openaiCompatible ?? {}) as Record<string, unknown>;
+
+ return {
+ ...msg,
+ content: filteredContent,
+ providerOptions: {
+ ...existingOpts,
+ openaiCompatible: {
+ ...existingCompat,
+ // Always set, even when empty. This is the key fix —
+ // the SDK's content-side extraction skips empty
+ // reasoning, but DeepSeek requires the field present.
+ reasoning_content: reasoningText,
+ },
+ },
+ } as ModelMessage;
+ });
+}
+
+/**
+ * Apply Anthropic structural normalisations to a `ModelMessage[]`.
+ *
+ * Cribbed from opencode `provider/transform.ts:53-148`. Two passes:
+ *
+ * 1. **Empty-text/reasoning filter**: Drop `text` / `reasoning` parts
+ * whose `text === ""`. Drop messages whose content array becomes
+ * empty. Anthropic rejects assistant turns with empty content.
+ *
+ * 2. **`toolCallId` scrubbing**: Anthropic only accepts tool call IDs
+ * that match `[a-zA-Z0-9_-]`. Our IDs are crypto.randomUUID() values
+ * which are already safe, but tool-call IDs assigned by upstream
+ * sources (provider-executed tools, subagent retrieval, etc.) may
+ * not be. Defensively scrub. Mirrors opencode `provider/transform.ts:96-122`.
+ *
+ * 3. **`[tool-call, text]` split**: Anthropic rejects assistant turns
+ * where `tool_use` blocks are followed by non-tool-call content
+ * ("`tool_use` ids were found without `tool_result` blocks immediately
+ * after"). If an assistant message has mixed ordering, split it into
+ * `[non-tool parts] + [tool-call parts]`.
+ *
+ * Only applied for Anthropic-backed providers (Claude OAuth or
+ * opencode-anthropic). Skip for openai-compatible / OpenCode Zen.
+ */
+const SCRUB_TOOL_CALL_ID = (id: string): string => id.replace(/[^a-zA-Z0-9_-]/g, "_");
+
+function applyAnthropicStructuralNormalisations(msgs: ModelMessage[]): ModelMessage[] {
+ // Pass 1: Filter empty text/reasoning parts; drop messages that become empty.
+ msgs = msgs
+ .map((msg) => {
+ if (typeof msg.content === "string") {
+ if (msg.content === "") return undefined;
+ return msg;
+ }
+ if (!Array.isArray(msg.content)) return msg;
+ const filtered = msg.content.filter((part) => {
+ if (part.type === "text" || part.type === "reasoning") {
+ return (part as { text: string }).text !== "";
+ }
+ return true;
+ });
+ if (filtered.length === 0) return undefined;
+ return { ...msg, content: filtered };
+ })
+ .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "");
+
+ // Pass 2: Scrub toolCallId chars on both assistant tool-call parts and
+ // tool-role tool-result parts. Anthropic rejects anything outside
+ // [a-zA-Z0-9_-]. Our internal IDs are crypto.randomUUID() and safe, but
+ // upstream provider-executed tools or external sources may not be.
+ msgs = msgs.map((msg) => {
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
+ return {
+ ...msg,
+ content: msg.content.map((part) => {
+ if (part.type === "tool-call" || part.type === "tool-result") {
+ return { ...part, toolCallId: SCRUB_TOOL_CALL_ID(part.toolCallId) };
+ }
+ return part;
+ }),
+ };
+ }
+ if (msg.role === "tool" && Array.isArray(msg.content)) {
+ return {
+ ...msg,
+ content: msg.content.map((part) => {
+ if (part.type === "tool-result") {
+ return { ...part, toolCallId: SCRUB_TOOL_CALL_ID(part.toolCallId) };
+ }
+ return part;
+ }),
+ };
+ }
+ return msg;
+ });
+
+ // Pass 3: Split assistant messages where tool-calls are followed by non-tool-call parts.
+ // [text, tool-call, text] → [text, text] + [tool-call] (text-only first, tools-only second)
+ msgs = msgs.flatMap((msg) => {
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg];
+ const parts = msg.content;
+ const firstToolCallIdx = parts.findIndex((part) => part.type === "tool-call");
+ if (firstToolCallIdx === -1) return [msg]; // no tool calls → pass through
+ // If everything from the first tool-call onward is also a tool-call, it's already valid
+ if (!parts.slice(firstToolCallIdx).some((part) => part.type !== "tool-call")) return [msg];
+ // Has non-tool-call content AFTER a tool-call → split
+ const nonToolParts = parts.filter((part) => part.type !== "tool-call");
+ const toolParts = parts.filter((part) => part.type === "tool-call");
+ return [
+ { ...msg, content: nonToolParts },
+ { ...msg, content: toolParts },
+ ];
+ });
+
+ return msgs;
+}
+
+/**
* Apply Anthropic prompt-caching breakpoints to a message list.
*
* Anthropic caches the entire request prefix up to (and including) any block
@@ -151,8 +323,8 @@ function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreM
* served via `@ai-sdk/openai` (GPT) and `@ai-sdk/google` (Gemini) likewise
* use server-side automatic caching.
*/
-function applyAnthropicCaching(msgs: CoreMessage[]): void {
- const targets = new Set<CoreMessage>();
+function applyAnthropicCaching(msgs: ModelMessage[]): void {
+ const targets = new Set<ModelMessage>();
const systemMsgs = msgs.filter((m) => m.role === "system").slice(0, 2);
for (const m of systemMsgs) targets.add(m);
@@ -447,14 +619,25 @@ export class Agent {
// `system` shortcut parameter takes a plain string with nowhere to
// attach `providerOptions.anthropic.cacheControl`. Moving it inline
// also lets us apply rolling cache breakpoints to the last messages.
- const systemMessage: CoreSystemMessage = { role: "system", content: systemPrompt };
- const coreMessages: CoreMessage[] = [
+ const systemMessage: SystemModelMessage = {
+ role: "system",
+ content: systemPrompt,
+ };
+
+ let coreMessages: ModelMessage[] = [
systemMessage,
- ...toCoreMessages(stepMessages, isClaudeOAuth),
+ ...toModelMessages(stepMessages, isClaudeOAuth),
];
+ // Apply provider-specific structural normalisations before
+ // sending. Anthropic and openai-compatible paths each have
+ // their own message-shape requirements; the two passes are
+ // mutually exclusive.
if (usesAnthropicSDK) {
+ coreMessages = applyAnthropicStructuralNormalisations(coreMessages);
applyAnthropicCaching(coreMessages);
+ } else {
+ coreMessages = applyOpenAICompatibleReasoningNormalisation(coreMessages);
}
const streamOptions: Parameters<typeof streamText>[0] = {
@@ -464,34 +647,68 @@ export class Agent {
};
if (isClaudeOAuth && effort !== "none") {
- // Opus 4.7 rejects `thinking: { type: "enabled" }` ("reasoning-
- // signature without reasoning") and only supports adaptive thinking.
- // `@ai-sdk/anthropic` v1.x can't emit `type: "adaptive"`, so we
- // leave `providerOptions.anthropic.thinking` unset and let the
- // custom fetch in `createClaudeOAuthProvider` inject the adaptive
- // shape into the request body. We still set `maxTokens` here so
- // the SDK serializes it — adaptive thinking spends from this
- // budget rather than a separate one.
+ // v6 native support for Opus 4.7 adaptive thinking via
+ // providerOptions. No more rewriteBodyForOpus47 body-
+ // injection hack needed.
+ //
+ // Budgets mirror opencode transform.ts:
+ // - max budget = 31999 (Anthropic's documented ceiling;
+ // `Math.min(31_999, model.limit.output - 1)` in
+ // opencode line 668)
+ // - high budget = 16000 (opencode line 662:
+ // `Math.min(16_000, Math.floor(model.limit.output/2-1))`)
+ // - medium/low: no opencode equivalent — sensible
+ // intermediate defaults.
+ //
+ // `maxOutputTokens` is the TOTAL output cap (thinking +
+ // response combined). Anthropic's standard output max is
+ // 32K for Claude 4.x; matching opencode's OUTPUT_TOKEN_MAX
+ // constant. The `budget_tokens` field is a thinking
+ // ceiling, not a requirement, so the model self-regulates
+ // thinking vs response within this total budget.
+ //
+ // Opus 4.7 (adaptive) specifics (mirrors opencode
+ // transform.ts:642-655):
+ // - `thinking.type = "adaptive"` — the model decides how
+ // much to think within `maxOutputTokens`.
+ // - `thinking.display = "summarized"` — adaptive thinking
+ // for Opus 4.7 defaults to `display: "omitted"`, which
+ // means NO thinking content is streamed to us. Setting
+ // `summarized` is what makes the thinking visible in
+ // the UI.
+ // - `effort` (sibling of `thinking`) — adaptive needs an
+ // explicit effort level. Without it, Anthropic uses a
+ // conservative default and the model barely thinks.
const isOpus47 = this.config.model === "claude-opus-4-7";
const budgetTokens =
effort === "max"
- ? 16000
+ ? 31999
: effort === "high"
- ? 10000
+ ? 16000
: effort === "medium"
? 5000
: effort === "low"
? 2000
: 0;
- if (isOpus47) {
- streamOptions.maxTokens = budgetTokens + 8000;
- } else {
- streamOptions.providerOptions = {
- anthropic: { thinking: { type: "enabled" as const, budgetTokens } },
- };
- streamOptions.maxTokens = budgetTokens + 8000;
- }
+ // Cap at Anthropic's standard 32K output (matches opencode's
+ // OUTPUT_TOKEN_MAX); the model splits this between thinking
+ // (up to budgetTokens) and response.
+ streamOptions.maxOutputTokens = 32000;
+ streamOptions.providerOptions = {
+ anthropic: isOpus47
+ ? {
+ thinking: { type: "adaptive" as const, display: "summarized" as const },
+ effort,
+ }
+ : { thinking: { type: "enabled" as const, budgetTokens } },
+ };
} else if (!usesAnthropicSDK && effort !== "none") {
+ // OpenAI-compatible models (OpenCode Zen: DeepSeek, GLM,
+ // Kimi, etc.). The `reasoningEffort` field is forwarded
+ // verbatim to providers that support it. `"max"` is the
+ // strongest level we expose; the provider may map it
+ // internally (e.g. DeepSeek interprets it as deep
+ // reasoning).
streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } };
}
@@ -513,31 +730,100 @@ export class Agent {
try {
for await (const event of result.fullStream) {
if (event.type === "text-delta") {
+ // v6: text-delta carries `text` (not `textDelta`)
const internalEvent: AgentEvent = {
type: "text-delta",
- delta: event.textDelta,
+ delta: event.text,
};
appendEventToChunks(chunks, internalEvent);
yield internalEvent;
- } else if (event.type === "reasoning") {
+ } else if (event.type === "reasoning-delta") {
+ // v6 new event: reasoning-delta carries `text` (not `textDelta`)
const internalEvent: AgentEvent = {
type: "reasoning-delta",
- delta: event.textDelta,
+ delta: event.text,
};
appendEventToChunks(chunks, internalEvent);
yield internalEvent;
+ } else if (event.type === "reasoning-end") {
+ // Only emit when providerMetadata is present — non-Anthropic
+ // models send reasoning-end without any metadata to round-trip.
+ // Anthropic's signature lives inside providerMetadata as
+ // { anthropic: { signature: "..." } }.
+ if (event.providerMetadata !== undefined) {
+ const internalEvent: AgentEvent = {
+ type: "reasoning-end",
+ metadata: event.providerMetadata as Record<string, unknown>,
+ };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ }
} else if (event.type === "tool-call") {
+ // v6: tool call input is in `input` (not `args`)
const rawName = event.toolName;
const toolName = isClaudeOAuth ? unprefixToolName(rawName) : rawName;
const toolCall: ToolCall = {
id: event.toolCallId,
name: toolName,
- arguments: event.args as Record<string, unknown>,
+ arguments: event.input as Record<string, unknown>,
};
stepToolCalls.push(toolCall);
const internalEvent: AgentEvent = { type: "tool-call", toolCall };
appendEventToChunks(chunks, internalEvent);
yield internalEvent;
+ } else if (event.type === "tool-error") {
+ // Anthropic-side / provider-executed tool failures
+ // (e.g. server tools or repair-tool-call paths) surface
+ // as a fresh stream event rather than as a thrown
+ // `tool-result` from our manual executor. Forward both
+ // a synthetic tool-result (so the tool-batch entry the
+ // model expects has an `isError: true` result) and
+ // abort the step as an error chunk — without this the
+ // model would silently wait for a result that never
+ // arrives.
+ const evt = event as unknown as {
+ toolCallId: string;
+ toolName: string;
+ error: unknown;
+ };
+ const toolName = isClaudeOAuth ? unprefixToolName(evt.toolName) : evt.toolName;
+ const errMessage = evt.error instanceof Error ? evt.error.message : String(evt.error);
+
+ const trEvent: AgentEvent = {
+ type: "tool-result",
+ toolResult: {
+ toolCallId: evt.toolCallId,
+ toolName,
+ result: `Error: ${errMessage}`,
+ isError: true,
+ },
+ };
+ appendEventToChunks(chunks, trEvent);
+ yield trEvent;
+
+ const errorMsg = formatError(evt.error, this.config);
+ const errChunkEvent: AgentEvent = { type: "error", error: errorMsg };
+ appendEventToChunks(chunks, errChunkEvent);
+ yield errChunkEvent;
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ } else if (event.type === "abort") {
+ // Stream aborted upstream. Surface as an error so the
+ // frontend tab transitions out of `running`. We don't
+ // currently call abortController.abort() ourselves, so
+ // this would only fire from external signal propagation.
+ const reason =
+ typeof (event as { reason?: unknown }).reason === "string"
+ ? (event as { reason: string }).reason
+ : "stream aborted";
+ const errorMsg = formatError(new Error(reason), this.config);
+ const internalEvent: AgentEvent = { type: "error", error: errorMsg };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
} else if (event.type === "error") {
const errRecord = event.error as unknown as Record<string, unknown>;
const statusCode =
@@ -554,9 +840,17 @@ export class Agent {
yield { type: "status", status: "error" };
return;
}
+ // Ignored events (intentional):
+ // start, text-start, text-end, reasoning-start,
+ // tool-input-start, tool-input-delta, tool-input-end,
+ // tool-result (only fires if tool has execute; ours don't),
+ // start-step, finish-step, finish, raw,
+ // source, file, tool-output-denied, tool-approval-*
}
} catch (streamErr) {
const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
+ // v6 SDK error message: "Model tried to call unavailable tool '...'"
+ // (v4 was: "tried to call unavailable tool '...'")
const unavailMatch = errMsg.match(/tried to call unavailable tool '([^']+)'/i);
if (!unavailMatch) throw streamErr;
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
index 9c2a367..fe384bd 100644
--- a/packages/core/src/chunks/append.ts
+++ b/packages/core/src/chunks/append.ts
@@ -8,18 +8,31 @@
*
* Open/close rules — see plan-chunk-refactor.md for the full table.
*
- * | Chunk | Opens on | Coalesces |
- * |---------------|-------------------------------------------------------|------------------------------------------------------------|
- * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
- * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
- * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
- * | `error` | every `error` event | NEVER (always single-event) |
- * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
+ * | Chunk | Opens on | Coalesces |
+ * |---------------|-----------------------------------------------------------------|------------------------------------------------------------|
+ * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
+ * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
+ * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) |
+ * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
+ * | `error` | every `error` event | NEVER (always single-event) |
+ * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
*
* Side-effect events (no new chunk):
- * - `tool-result` → finds the call by `id` across all `tool-batch` chunks (most-recent first)
- * and updates its `result` / `isError`.
- * - `shell-output` → appends to the most recent entry of the most recent `tool-batch` chunk.
+ * - `tool-result` → finds the call by `id` across all `tool-batch`
+ * chunks (most-recent first) and updates its
+ * `result` / `isError`.
+ * - `shell-output` → appends to the most recent entry of the most
+ * recent `tool-batch` chunk.
+ * - `reasoning-end` → attaches `metadata` (the AI SDK v6
+ * `providerMetadata` blob) to the most recent
+ * UNSEALED `thinking` chunk. The metadata is also
+ * the "sealed" marker — subsequent
+ * `reasoning-delta`s will open a new chunk rather
+ * than extending this one. Anthropic's signature
+ * lives inside this blob; round-tripping it on the
+ * next turn is mandatory for Anthropic to accept
+ * the conversation. Orphan `reasoning-end` events
+ * (no unsealed thinking chunk) are dropped.
*
* Ignored events:
* - `status`, `done`, `task-list-update`, `tab-created`, `message-queued`,
@@ -56,9 +69,14 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
}
case "reasoning-delta": {
- // Open or extend the current thinking chunk.
+ // Open a new thinking chunk if the last chunk is not a thinking
+ // chunk OR if it's already sealed by metadata. Anthropic emits
+ // each thinking content block with its own metadata; a fresh
+ // reasoning-delta after a sealed thinking chunk is the start of
+ // a new block, not a continuation — extending the sealed chunk
+ // would corrupt the metadata/text mapping.
const last = chunks[chunks.length - 1];
- if (last && last.type === "thinking") {
+ if (last && last.type === "thinking" && last.metadata === undefined) {
last.text += event.delta;
} else {
chunks.push({ type: "thinking", text: event.delta });
@@ -66,6 +84,30 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
return;
}
+ case "reasoning-end": {
+ // Attach `providerMetadata` to the most recent unsealed
+ // thinking chunk. Anthropic's signature lives inside this
+ // blob; without it on the next request, Anthropic rejects the
+ // thinking block. The walk-back is a defensive backstop —
+ // Anthropic's SSE delivers a content block's deltas strictly
+ // in order and `appendEventToChunks` runs synchronously per
+ // event, so the most recent thinking chunk is normally the
+ // last chunk in the array.
+ if (event.metadata === undefined) return;
+ for (let i = chunks.length - 1; i >= 0; i--) {
+ const c = chunks[i];
+ if (!c || c.type !== "thinking") continue;
+ if (c.metadata !== undefined) {
+ // Already sealed; the orphan metadata has no home.
+ return;
+ }
+ c.metadata = event.metadata;
+ return;
+ }
+ // No thinking chunk found at all — drop silently.
+ return;
+ }
+
case "tool-call": {
// Open or extend the current tool-batch chunk.
const last = chunks[chunks.length - 1];
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
index a7d800c..81f7f51 100644
--- a/packages/core/src/llm/provider.ts
+++ b/packages/core/src/llm/provider.ts
@@ -1,42 +1,6 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
-import type { LanguageModelV1, LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai";
-import { wrapLanguageModel } from "ai";
-
-function normalizeMessages(msgs: unknown[]): unknown[] {
- return msgs.map((msg: unknown) => {
- const message = msg as Record<string, unknown>;
- if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
-
- const content = message.content as Array<Record<string, unknown>>;
- const reasoningParts = content.filter(
- (p) => p.type === "reasoning" || p.type === "redacted-reasoning",
- );
- const reasoningText = reasoningParts.map((p) => p.text ?? "").join("");
-
- const filteredContent = content.filter(
- (p) => p.type !== "reasoning" && p.type !== "redacted-reasoning",
- );
-
- const existingMetadata = (message.providerMetadata ?? {}) as Record<string, unknown>;
- const existingOpenAICompat = (existingMetadata.openaiCompatible ?? {}) as Record<
- string,
- unknown
- >;
-
- return {
- ...message,
- content: filteredContent,
- providerMetadata: {
- ...existingMetadata,
- openaiCompatible: {
- ...existingOpenAICompat,
- reasoning_content: reasoningText,
- },
- },
- };
- });
-}
+import type { LanguageModelV3 } from "@ai-sdk/provider";
export interface ProviderConfig {
apiKey: string;
@@ -63,9 +27,9 @@ function unprefixToolName(name: string): string {
// Explicit factory return type so the inferred type doesn't leak references
// into transitive `@ai-sdk/provider` paths (which would trip TS2742).
-// `@ai-sdk/anthropic` v1.x already returns `LanguageModelV1`-spec models;
-// `@ai-sdk/openai-compatible` v0.2.x and `wrapLanguageModel` likewise.
-export type ModelFactory = (modelId: string) => LanguageModelV1;
+// `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return
+// `LanguageModelV3`-spec models; `wrapLanguageModel` likewise.
+export type ModelFactory = (modelId: string) => LanguageModelV3;
export function createProvider(config: ProviderConfig): ModelFactory {
if (config.provider === "anthropic") {
@@ -76,115 +40,52 @@ export function createProvider(config: ProviderConfig): ModelFactory {
return createApiKeyAnthropicProvider(config);
}
- // Default: OpenAI-compatible provider
+ // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM,
+ // Kimi, MiniMax, etc.).
+ //
+ // `@ai-sdk/[email protected]` handles reasoning round-tripping
+ // natively: it reads `{ type: "reasoning", text }` parts from each
+ // assistant message's content and emits them as `reasoning_content`
+ // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs
+ // lines 215-216 and 245). Our `toModelMessages` in agent.ts already
+ // emits reasoning parts from `ThinkingChunk`s, so no middleware is
+ // needed.
+ //
+ // (The v4-era `normalizeMessages` middleware that lived here was
+ // actively breaking DeepSeek: it stripped reasoning parts from
+ // content AND wrote them under `providerMetadata` — wrong key in v3
+ // prompts, which use `providerOptions`. The result was that
+ // reasoning_content never reached the wire and DeepSeek rejected the
+ // follow-up turn with "must be passed back".)
const provider = createOpenAICompatible({
name: "opencode-zen",
apiKey: config.apiKey,
baseURL: config.baseURL,
});
- return (modelId: string) => {
- const middleware: LanguageModelV1Middleware = {
- middlewareVersion: "v1" as const,
- transformParams: async ({ type, params }) => {
- if (type === "stream" && params.prompt) {
- return {
- ...params,
- prompt: normalizeMessages(params.prompt as unknown[]) as LanguageModelV1Prompt,
- };
- }
- return params;
- },
- };
-
- return wrapLanguageModel({
- model: provider(modelId),
- middleware: [middleware],
- });
- };
+ return (modelId: string) => provider(modelId);
}
/**
* Claude OAuth provider. Used by Dispatch's `anthropic` provider keys
- * (claude-pro, claude-max). Swaps `x-api-key` for `Authorization: Bearer`
- * to satisfy Anthropic's OAuth flow, and mimics Claude Code CLI request
- * headers so the request bills against the user's Claude subscription.
- *
- * The custom fetch also rewrites the outgoing JSON body for Claude Opus 4.7:
- * that model rejects `thinking: { type: "enabled", budget_tokens }` (the only
- * shape `@ai-sdk/anthropic` v1.x can emit) with "reasoning-signature without
- * reasoning", and instead requires `thinking: { type: "adaptive" }`. `ai` v4
- * is pinned to V1-spec providers, so we can't upgrade to v3 of the Anthropic
- * SDK without breaking everything. Doing the rewrite here keeps the rest of
- * the agent path SDK-agnostic and limits the special case to one model.
+ * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
+ * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
+ * request headers so the request bills against the user's Claude subscription.
*/
function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
- const accessToken = config.claudeCredentials?.accessToken ?? config.apiKey;
-
- const customFetch = Object.assign(
- async (url: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
- const headers = new Headers(init?.headers);
- headers.delete("x-api-key");
- headers.set("authorization", `Bearer ${accessToken}`);
-
- const body = rewriteBodyForOpus47(init?.body);
- return globalThis.fetch(url, { ...init, headers, body });
- },
- { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) },
- );
-
const anthropic = createAnthropic({
- apiKey: "sk-ant-oauth-placeholder",
baseURL: config.baseURL || "https://api.anthropic.com/v1",
+ authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
headers: {
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
},
- fetch: customFetch as typeof globalThis.fetch,
});
-
return (modelId: string) => anthropic(modelId);
}
/**
- * If the request body is a JSON `/messages` payload targeting Claude Opus 4.7
- * and the caller signaled they want thinking (by setting `max_tokens` above
- * Anthropic's default 4096), insert `thinking: { type: "adaptive" }`.
- *
- * Skipping the rewrite when `max_tokens` is small (or absent) keeps `effort:
- * "none"` requests as plain non-thinking calls — agent.ts only sets a high
- * `max_tokens` when thinking is wanted, so this acts as a clean signal.
- *
- * Returns the body unchanged for any other model, any non-string body, or any
- * payload that fails to parse, leaving non-Anthropic providers, non-Opus-4.7
- * Claude models, and streaming/binary uploads unaffected.
- */
-function rewriteBodyForOpus47(body: BodyInit | null | undefined): BodyInit | null | undefined {
- if (typeof body !== "string") return body;
- let parsed: Record<string, unknown>;
- try {
- parsed = JSON.parse(body) as Record<string, unknown>;
- } catch {
- return body;
- }
- if (parsed.model !== "claude-opus-4-7") return body;
- const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : 0;
- if (maxTokens <= 4096) return body;
- parsed.thinking = { type: "adaptive" };
- // Anthropic rejects requests that combine extended thinking (enabled or
- // adaptive) with any temperature other than 1. `ai` v4 defaults
- // `temperature: 0`, and the v1 Anthropic SDK normally strips it when its
- // own `isThinking` flag is set — but we're injecting `thinking` here,
- // behind the SDK's back, so we have to strip it ourselves. Same for
- // `top_p` and `top_k`, which are likewise rejected with thinking.
- delete parsed.temperature;
- delete parsed.top_p;
- delete parsed.top_k;
- return JSON.stringify(parsed);
-}
-
-/**
* Plain-API-key Anthropic-format provider. Used to hit gateways that speak
* Anthropic's `/messages` protocol with a standard `x-api-key` header — most
* importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
index 0c7b110..a09535e 100644
--- a/packages/core/src/tools/registry.ts
+++ b/packages/core/src/tools/registry.ts
@@ -1,7 +1,25 @@
-import { tool } from "ai";
-import { z } from "zod";
+import type { Tool } from "ai";
+import { jsonSchema, tool } from "ai";
+import { zodToJsonSchema } from "zod-to-json-schema";
import type { ToolDefinition } from "../types/index.js";
+/**
+ * Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6
+ * `Tool` object.
+ *
+ * Critically, NO `execute` function is attached. The agent's manual tool
+ * loop (see agent.ts) handles execution itself — for permission prompts,
+ * shell-output streaming, and queued-message injection. Without `execute`,
+ * the SDK never auto-runs tools; it only surfaces `tool-call` events from
+ * `fullStream` that agent.ts collects and dispatches.
+ */
+function toAISDKTool(def: ToolDefinition): Tool {
+ return tool({
+ description: def.description,
+ inputSchema: jsonSchema(zodToJsonSchema(def.parameters)),
+ });
+}
+
export function createToolRegistry(tools: ToolDefinition[]) {
const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
@@ -14,19 +32,21 @@ export function createToolRegistry(tools: ToolDefinition[]) {
return toolMap.get(name);
},
- getAISDKTools() {
- const result: Record<string, ReturnType<typeof tool>> = {};
+ /**
+ * Returns AI SDK v6 `Tool` objects keyed by tool name, for passing
+ * directly to `streamText({ tools })`.
+ *
+ * Each tool has:
+ * - `description` — forwarded verbatim from the internal definition.
+ * - `inputSchema` — Zod schema converted to JSONSchema7 via
+ * `zod-to-json-schema`, then wrapped with the v6
+ * `jsonSchema()` helper.
+ * - NO `execute` — intentional; see `toAISDKTool` above.
+ */
+ getAISDKTools(): Record<string, Tool> {
+ const result: Record<string, Tool> = {};
for (const [name, def] of toolMap) {
- const schema = def.parameters;
- // Do NOT pass execute here — agent.ts handles tool execution
- // manually via executeToolWithStreaming. Passing execute would
- // cause the AI SDK to auto-execute tools AND agent.ts to execute
- // them again, resulting in double execution.
- const t = tool({
- description: def.description,
- parameters: schema instanceof z.ZodObject ? schema : z.object({}),
- });
- result[name] = t as unknown as ReturnType<typeof tool>;
+ result[name] = toAISDKTool(def);
}
return result;
},
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 8985bd9..e59eb68 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -26,6 +26,23 @@ export interface TextChunk {
export interface ThinkingChunk {
type: "thinking";
text: string;
+ /**
+ * Full Anthropic `providerMetadata` blob captured from the v6
+ * `reasoning-end` stream event (typically `{ anthropic: { signature
+ * } }` plus any other provider-side metadata). Round-tripped verbatim
+ * as `providerOptions` on the `ReasoningPart` of the next request so
+ * Anthropic can validate the thinking block's signature.
+ *
+ * Also acts as a "sealed" marker for `appendEventToChunks`: once
+ * `metadata` is set, the next `reasoning-delta` opens a new thinking
+ * chunk rather than extending this one (each Anthropic content block
+ * gets its own metadata, so two consecutive thinking blocks must not
+ * be coalesced).
+ *
+ * Optional: non-Anthropic models produce no metadata, and pre-v6
+ * persisted chunks have neither field.
+ */
+ metadata?: Record<string, unknown>;
}
export interface ToolBatchChunk {
@@ -82,6 +99,13 @@ export type AgentEvent =
| { type: "status"; status: AgentStatus }
| { type: "text-delta"; delta: string }
| { type: "reasoning-delta"; delta: string }
+ /**
+ * Emitted on the v6 `reasoning-end` stream event when it carries
+ * `providerMetadata`. `appendEventToChunks` attaches the metadata to
+ * the most recent unsealed `thinking` chunk; `toModelMessages` reads
+ * it back as `providerOptions` on the next request's `ReasoningPart`.
+ */
+ | { type: "reasoning-end"; metadata?: Record<string, unknown> }
| { type: "tool-call"; toolCall: ToolCall }
| { type: "tool-result"; toolResult: ToolResult }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 3d11fc5..82ca830 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
-import type { AgentConfig } from "../../src/types/index.js";
+import type { AgentConfig, AgentEvent } from "../../src/types/index.js";
// Mock bun:sqlite to avoid Bun-only import in vitest/Node
vi.mock("../../src/db/index.js", () => ({
@@ -31,6 +31,7 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
}));
const { Agent } = await import("../../src/agent/agent.js");
+const { streamText } = await import("ai");
function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
return {
@@ -58,6 +59,21 @@ function makeMockStreamResult(events: Array<{ type: string; [key: string]: unkno
} as ReturnType<typeof import("ai").streamText>;
}
+// v6 finish event — only finishReason, rawFinishReason, totalUsage (no usage/providerMetadata/response)
+const finishStop = {
+ type: "finish",
+ finishReason: "stop",
+ rawFinishReason: "stop",
+ totalUsage: { inputTokens: 10, outputTokens: 5 },
+};
+
+const finishToolCalls = {
+ type: "finish",
+ finishReason: "tool-calls",
+ rawFinishReason: "tool_use",
+ totalUsage: { inputTokens: 10, outputTokens: 5 },
+};
+
describe("Agent", () => {
it("starts in idle status", () => {
const agent = new Agent(makeConfig());
@@ -70,17 +86,11 @@ describe("Agent", () => {
});
it("yields running then idle status events around a simple message", async () => {
- const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
makeMockStreamResult([
- { type: "text-delta", textDelta: "Hello!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
+ // v6: text-delta uses `text` (not `textDelta`)
+ { type: "text-delta", id: "t0", text: "Hello!" },
+ finishStop,
]),
);
@@ -99,18 +109,12 @@ describe("Agent", () => {
});
it("yields text-delta events", async () => {
- const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
makeMockStreamResult([
- { type: "text-delta", textDelta: "Hello" },
- { type: "text-delta", textDelta: " world" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
+ // v6: text-delta uses `text` (not `textDelta`)
+ { type: "text-delta", id: "t0", text: "Hello" },
+ { type: "text-delta", id: "t0", text: " world" },
+ finishStop,
]),
);
@@ -127,18 +131,8 @@ describe("Agent", () => {
});
it("adds user message and assistant message to history", async () => {
- const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", textDelta: "Response" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ]),
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Response" }, finishStop]),
);
const agent = new Agent(makeConfig());
@@ -158,18 +152,8 @@ describe("Agent", () => {
});
it("yields done event with final message", async () => {
- const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", textDelta: "Done!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ]),
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done!" }, finishStop]),
);
const agent = new Agent(makeConfig());
@@ -187,8 +171,6 @@ describe("Agent", () => {
});
it("yields tool-call and tool-result events", async () => {
- const { streamText } = await import("ai");
-
// First call: LLM emits a tool-call
// Second call (after tool execution): LLM emits text response with no tool calls
vi.mocked(streamText)
@@ -198,27 +180,16 @@ describe("Agent", () => {
type: "tool-call",
toolCallId: "tc1",
toolName: "read_file",
- args: { path: "hello.txt" },
- },
- {
- type: "finish",
- finishReason: "tool-calls",
- usage: {},
- providerMetadata: undefined,
- response: {},
+ // v6: `input` replaces `args`
+ input: { path: "hello.txt" },
},
+ finishToolCalls,
]),
)
.mockReturnValueOnce(
makeMockStreamResult([
- { type: "text-delta", textDelta: "Here is the file." },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
+ { type: "text-delta", id: "t0", text: "Here is the file." },
+ finishStop,
]),
);
@@ -247,4 +218,754 @@ describe("Agent", () => {
toolResult: { toolCallId: "tc1", result: "file contents" },
});
});
+
+ it("yields reasoning-delta events", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ // v6: reasoning-delta uses `text` (not `textDelta`)
+ { type: "reasoning-delta", id: "r0", text: "thinking about this..." },
+ { type: "reasoning-delta", id: "r0", text: " more thoughts" },
+ { type: "text-delta", id: "t0", text: "Answer" },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("think")) {
+ events.push(event);
+ }
+
+ const reasoningDeltas = events.filter((e) => e.type === "reasoning-delta");
+ expect(reasoningDeltas).toHaveLength(2);
+ expect(reasoningDeltas[0]).toMatchObject({ delta: "thinking about this..." });
+ expect(reasoningDeltas[1]).toMatchObject({ delta: " more thoughts" });
+ });
+
+ it("yields reasoning-end event when providerMetadata is present", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ { type: "reasoning-delta", id: "r0", text: "some reasoning" },
+ {
+ type: "reasoning-end",
+ id: "r0",
+ providerMetadata: { anthropic: { signature: "sig-1" } },
+ },
+ { type: "text-delta", id: "t0", text: "Answer" },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("think")) {
+ events.push(event);
+ }
+
+ const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
+ expect(reasoningEndEvent).toBeDefined();
+ expect(reasoningEndEvent).toMatchObject({
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "sig-1" } },
+ });
+ });
+
+ it("does NOT yield reasoning-end event when providerMetadata is absent", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ { type: "reasoning-delta", id: "r0", text: "some reasoning" },
+ {
+ type: "reasoning-end",
+ id: "r0",
+ // No providerMetadata — non-Anthropic model
+ },
+ { type: "text-delta", id: "t0", text: "Answer" },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events = [];
+ for await (const event of agent.run("think")) {
+ events.push(event);
+ }
+
+ const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
+ expect(reasoningEndEvent).toBeUndefined();
+ });
+
+ // ─── New v6 round-trip tests ──────────────────────────────────────────────
+
+ it("signed thinking round-trip: ThinkingChunk.metadata → ReasoningPart.providerOptions", async () => {
+ // Pre-seed the agent with a prior assistant message containing a ThinkingChunk
+ // with metadata (the Anthropic signature blob).
+ // Anthropic-path provider — for openai-compatible the metadata
+ // would be lifted into providerOptions.openaiCompatible instead;
+ // that path is covered by the DeepSeek tests further down.
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "prior user message" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ {
+ type: "thinking",
+ text: "I thought about it",
+ metadata: { anthropic: { signature: "S" } },
+ },
+ { type: "text", text: "prior response" },
+ ],
+ });
+
+ // Next turn: just return a simple text response
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "New answer" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ // Inspect the messages passed to streamText in this (last) call
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ expect(callArgs).toBeDefined();
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ content: unknown;
+ }>;
+
+ // Find the assistant message in the rebuilt ModelMessage[]
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ expect(assistantMsg).toBeDefined();
+ const content = assistantMsg?.content as Array<Record<string, unknown>>;
+ const reasoningPart = content.find((p) => p.type === "reasoning");
+ expect(reasoningPart).toBeDefined();
+ expect(reasoningPart).toMatchObject({
+ type: "reasoning",
+ text: "I thought about it",
+ providerOptions: { anthropic: { signature: "S" } },
+ });
+ });
+
+ it("tool-call input round-trip: ToolBatchEntry.arguments → ToolCallPart.input (not args)", async () => {
+ // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
+ const agent = new Agent(makeConfig());
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "run a tool" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "call-1",
+ name: "read_file",
+ arguments: { path: "/foo/bar.txt" },
+ result: "file contents",
+ },
+ ],
+ },
+ ],
+ });
+
+ // Next turn: just return a simple text response
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ // The assistant message should contain a tool-call part with `input` (not `args`)
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ expect(assistantMsg).toBeDefined();
+ const content = assistantMsg?.content as Array<Record<string, unknown>>;
+ const toolCallPart = content.find((p) => p.type === "tool-call");
+ expect(toolCallPart).toBeDefined();
+ expect(toolCallPart).toMatchObject({
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "read_file",
+ input: { path: "/foo/bar.txt" }, // v6: input not args
+ });
+ // Explicitly assert `args` is NOT present
+ expect(toolCallPart).not.toHaveProperty("args");
+ });
+
+ it("tool-result output round-trip: result string → { type: 'text', value } ToolResultOutput", async () => {
+ // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
+ const agent = new Agent(makeConfig());
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "run a tool" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "call-2",
+ name: "read_file",
+ arguments: { path: "/foo/baz.txt" },
+ result: "the file content here",
+ },
+ ],
+ },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ // The tool message should contain a tool-result part with `output` (ToolResultOutput)
+ const toolMsg = messages.find((m) => m.role === "tool");
+ expect(toolMsg).toBeDefined();
+ const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
+ expect(toolContent[0]).toMatchObject({
+ type: "tool-result",
+ toolCallId: "call-2",
+ toolName: "read_file",
+ output: { type: "text", value: "the file content here" },
+ });
+ // Explicitly assert `result` (v4 raw string) is NOT present
+ expect(toolContent[0]).not.toHaveProperty("result");
+ });
+
+ it("Anthropic [tool-call, text] split: mixed-order assistant message gets split into [text]+[tool-call]", async () => {
+ // Pre-seed an assistant message with chunks in [tool-batch, text] order —
+ // which produces [tool-call, text] in the ModelMessage content, a shape
+ // Anthropic rejects. Only applies for anthropic / opencode-anthropic provider.
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "run a tool and explain" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ // Note: tool-batch appears BEFORE text in chunks — this is the
+ // problematic ordering that Anthropic rejects
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "call-3",
+ name: "read_file",
+ arguments: { path: "/tmp/x.txt" },
+ result: "x contents",
+ },
+ ],
+ },
+ { type: "text", text: "Here is my explanation." },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ // After Anthropic structural normalisation, we should have TWO assistant messages:
+ // 1st: text-only content
+ // 2nd: tool-call-only content
+ const assistantMsgs = messages.filter((m) => m.role === "assistant");
+ expect(assistantMsgs.length).toBeGreaterThanOrEqual(2);
+
+ // Find the text-only assistant message and the tool-call-only assistant message
+ const textOnlyMsg = assistantMsgs.find((m) => {
+ const c = m.content as Array<Record<string, unknown>>;
+ return Array.isArray(c) && c.every((p) => p.type !== "tool-call");
+ });
+ const toolOnlyMsg = assistantMsgs.find((m) => {
+ const c = m.content as Array<Record<string, unknown>>;
+ return Array.isArray(c) && c.every((p) => p.type === "tool-call");
+ });
+
+ // Narrow the optionals — toBeDefined() already verified non-null,
+ // but TypeScript needs the explicit assertion via local consts so
+ // we can pass them to indexOf without `!`.
+ if (!textOnlyMsg || !toolOnlyMsg) throw new Error("type guard");
+
+ // Text message comes first (before tool-call message) — Anthropic requires this ordering
+ expect(messages.indexOf(textOnlyMsg)).toBeLessThan(messages.indexOf(toolOnlyMsg));
+ });
+
+ it("Anthropic [tool-call, text] split: openai-compatible provider preserves original order (no split)", async () => {
+ // For non-Anthropic providers, the [tool-call, text] split should NOT be applied.
+ // (No provider set → defaults to openai-compatible)
+ const agent = new Agent(makeConfig());
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "run a tool and explain" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "call-4",
+ name: "read_file",
+ arguments: { path: "/tmp/y.txt" },
+ result: "y contents",
+ },
+ ],
+ },
+ { type: "text", text: "Here is my explanation." },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ // For openai-compatible provider, only ONE assistant message with mixed content
+ const assistantMsgs = messages.filter((m) => m.role === "assistant");
+ expect(assistantMsgs).toHaveLength(1);
+ const content = assistantMsgs[0]?.content as Array<Record<string, unknown>>;
+ // Both tool-call and text parts should be in the same message
+ expect(content.some((p) => p.type === "tool-call")).toBe(true);
+ expect(content.some((p) => p.type === "text")).toBe(true);
+ });
+
+ it("empty-text-part filter (Anthropic): empty text chunk is not sent", async () => {
+ // Pre-seed an assistant message where a text chunk has empty text.
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ agent.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: "hello" }],
+ });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ { type: "text", text: "" }, // empty text — should be filtered out
+ { type: "text", text: "non-empty response" },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ expect(assistantMsg).toBeDefined();
+ const content = assistantMsg?.content as Array<Record<string, unknown>>;
+
+ // Empty text part should have been filtered out
+ const emptyTextParts = content.filter((p) => p.type === "text" && p.text === "");
+ expect(emptyTextParts).toHaveLength(0);
+
+ // The non-empty text part should still be there
+ const nonEmptyTextParts = content.filter((p) => p.type === "text" && p.text !== "");
+ expect(nonEmptyTextParts).toHaveLength(1);
+ expect(nonEmptyTextParts[0]).toMatchObject({ text: "non-empty response" });
+ });
+
+ it("empty-reasoning-part filter (Anthropic): empty reasoning chunk is not sent", async () => {
+ // Anthropic's adaptive thinking mode occasionally produces a signed-
+ // but-empty thinking block. We persist it (for signature round-trip
+ // fidelity) but strip the empty `reasoning` part before sending it
+ // back, or Anthropic rejects with "thinking block must have content".
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ agent.messages.push({ role: "user", chunks: [{ type: "text", text: "hi" }] });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ // Signed-but-empty thinking block
+ { type: "thinking", text: "", metadata: { anthropic: { signature: "sig-empty" } } },
+ { type: "text", text: "answer" },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ const content = assistantMsg?.content as Array<Record<string, unknown>>;
+
+ // Empty reasoning part must have been filtered out by the
+ // Anthropic structural normalisation pass.
+ const emptyReasoning = content.filter((p) => p.type === "reasoning" && p.text === "");
+ expect(emptyReasoning).toHaveLength(0);
+
+ // The text part should still be there
+ expect(content.some((p) => p.type === "text" && p.text === "answer")).toBe(true);
+ });
+
+ it("toolCallId scrubbing (Anthropic): non-[a-zA-Z0-9_-] chars in tool IDs are sanitised", async () => {
+ // Anthropic rejects toolCallId outside [a-zA-Z0-9_-]. Our internal
+ // crypto.randomUUID IDs are safe, but defensively scrub for any
+ // upstream-assigned IDs (subagent retrieval, provider-executed
+ // tools, MCP, etc.). Mirrors opencode transform.ts:96-122.
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ agent.messages.push({ role: "user", chunks: [{ type: "text", text: "do the thing" }] });
+ agent.messages.push({
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "call.with/dots:and:slashes", // invalid chars
+ name: "fake_tool",
+ arguments: { x: 1 },
+ result: "ok",
+ isError: false,
+ },
+ ],
+ },
+ ],
+ });
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+
+ // Assistant tool-call part must have scrubbed ID
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ const assistantContent = assistantMsg?.content as Array<Record<string, unknown>>;
+ const toolCallPart = assistantContent.find((p) => p.type === "tool-call");
+ expect(toolCallPart).toBeDefined();
+ expect(toolCallPart?.toolCallId).toBe("call_with_dots_and_slashes");
+
+ // Matching tool-result message must use the SAME scrubbed ID
+ // so Anthropic can pair them.
+ const toolMsg = messages.find((m) => m.role === "tool");
+ const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
+ expect(toolContent?.[0]?.toolCallId).toBe("call_with_dots_and_slashes");
+ });
+
+ it("reasoning metadata captured from stream is round-tripped on the next turn", async () => {
+ // End-to-end integrity for the providerMetadata round-trip — the
+ // bug that prompted the entire migration. Stream a turn that
+ // emits reasoning-delta + reasoning-end with metadata, then run
+ // ANOTHER turn and verify the metadata reaches the model via
+ // ReasoningPart.providerOptions.
+ const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
+ const sig = { anthropic: { signature: "round-trip-sig-1" } };
+
+ // Turn 1: model emits reasoning + signed reasoning-end
+ vi.mocked(streamText).mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "reasoning-delta", id: "r0", text: "let me think" },
+ { type: "reasoning-end", id: "r0", providerMetadata: sig },
+ { type: "text-delta", id: "t0", text: "answer" },
+ finishStop,
+ ]),
+ );
+
+ for await (const _ of agent.run("first question")) {
+ /* consume */
+ }
+
+ // After turn 1, the persisted chunks should include a ThinkingChunk
+ // with the captured metadata. The agent's messages array IS the
+ // canonical persisted shape (the DB just JSON-stringifies it).
+ const turn1Assistant = agent.messages.find(
+ (m, i) => m.role === "assistant" && i === agent.messages.length - 1,
+ );
+ expect(turn1Assistant).toBeDefined();
+ const thinkingChunk = turn1Assistant?.chunks.find((c) => c.type === "thinking");
+ expect(thinkingChunk).toBeDefined();
+ expect(thinkingChunk).toMatchObject({ text: "let me think", metadata: sig });
+
+ // Turn 2: drive another turn, capture what streamText receives.
+ vi.mocked(streamText).mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "text-delta", id: "t1", text: "follow-up answer" },
+ finishStop,
+ ]),
+ );
+ for await (const _ of agent.run("second question")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+ const turn2Assistant = messages
+ .filter((m) => m.role === "assistant")
+ .find((m) => Array.isArray(m.content));
+ const turn2Content = turn2Assistant?.content as Array<Record<string, unknown>>;
+ const reasoningPart = turn2Content.find((p) => p.type === "reasoning");
+ expect(reasoningPart).toBeDefined();
+ expect(reasoningPart).toMatchObject({
+ type: "reasoning",
+ text: "let me think",
+ providerOptions: sig,
+ });
+ });
+
+ it("tool-error stream event yields a synthetic tool-result + error chunk and aborts the turn", async () => {
+ // Provider-executed tools (Anthropic server tools) bypass our
+ // manual executor and surface as a `tool-error` stream event.
+ // We must:
+ // 1. Synthesize a tool-result with isError=true so the chunks
+ // reflect that the tool ran and failed.
+ // 2. Emit an error chunk so the UI shows the failure.
+ // 3. Transition the agent to "error" status (no further steps).
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ {
+ type: "tool-error",
+ toolCallId: "tc_server",
+ toolName: "server_tool",
+ error: new Error("upstream tool failure"),
+ },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const event of agent.run("trigger")) {
+ events.push(event);
+ }
+
+ // Synthetic tool-result with the upstream error
+ const trEvent = events.find((e) => e.type === "tool-result");
+ expect(trEvent).toBeDefined();
+ expect(trEvent).toMatchObject({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc_server", isError: true },
+ });
+
+ // Error chunk
+ const errEvent = events.find((e) => e.type === "error");
+ expect(errEvent).toBeDefined();
+ const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
+ expect(typeof errMsg).toBe("string");
+ expect((errMsg as string).includes("upstream tool failure")).toBe(true);
+
+ // Status transitions to error
+ const errStatusEvent = events.filter((e) => e.type === "status").at(-1);
+ expect(errStatusEvent).toMatchObject({ type: "status", status: "error" });
+ });
+
+ it("abort stream event surfaces as an error event and stops the turn", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ { type: "text-delta", id: "t0", text: "starting..." },
+ { type: "abort", reason: "user cancelled" },
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const event of agent.run("hi")) {
+ events.push(event);
+ }
+
+ const errEvent = events.find((e) => e.type === "error");
+ expect(errEvent).toBeDefined();
+ const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
+ expect(
+ (errMsg as string).toLowerCase().includes("aborted") ||
+ (errMsg as string).includes("user cancelled"),
+ ).toBe(true);
+
+ const lastStatus = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatus).toMatchObject({ type: "status", status: "error" });
+ });
+
+ it("openai-compatible reasoning round-trip: ThinkingChunk -> providerOptions.openaiCompatible.reasoning_content (DeepSeek scenario)", async () => {
+ // Reproducer for the "reasoning_content must be passed back" error
+ // from DeepSeek via OpenCode Go.
+ //
+ // applyOpenAICompatibleReasoningNormalisation strips the
+ // `{ type: "reasoning", text }` parts and lifts the concatenated
+ // text into `providerOptions.openaiCompatible.reasoning_content`.
+ // The v6 SDK provider serializes the message-level
+ // `providerOptions.openaiCompatible.*` into the wire `assistant`
+ // message via its `metadata` spread (line 247 of the SDK dist).
+ // This route emits `reasoning_content` regardless of empty/non-
+ // empty text — which is what DeepSeek requires.
+ const agent = new Agent(
+ makeConfig({
+ model: "deepseek-v4-pro",
+ // no provider field → default openai-compatible path
+ }),
+ );
+ agent.messages.push(
+ { role: "user", chunks: [{ type: "text", text: "ping" }] },
+ {
+ role: "assistant",
+ chunks: [
+ { type: "thinking", text: "let me reason about this" },
+ { type: "text", text: "ok done" },
+ ],
+ },
+ );
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ content: unknown;
+ providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
+ }>;
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ if (!assistantMsg || !Array.isArray(assistantMsg.content)) {
+ throw new Error("expected structured assistant content");
+ }
+ const content = assistantMsg.content as Array<Record<string, unknown>>;
+
+ // Reasoning parts have been stripped from content (lifted into
+ // providerOptions instead).
+ expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
+
+ // reasoning_content is set on providerOptions.openaiCompatible.
+ // This is what reaches DeepSeek and prevents the rejection.
+ expect(assistantMsg.providerOptions?.openaiCompatible?.reasoning_content).toBe(
+ "let me reason about this",
+ );
+
+ // The text part still survives in content.
+ const textPart = content.find((p) => p.type === "text");
+ expect(textPart).toMatchObject({ type: "text", text: "ok done" });
+
+ // And critically, the message must NOT carry a providerMetadata
+ // key (the v4-era misnamed key). v3 prompts use `providerOptions`.
+ expect((assistantMsg as Record<string, unknown>).providerMetadata).toBeUndefined();
+ });
+
+ it("openai-compatible empty-reasoning edge case: forces reasoning_content='' so DeepSeek does not reject", async () => {
+ // DeepSeek will reject the follow-up turn with "must be passed
+ // back" if a prior assistant turn emitted reasoning AND the
+ // follow-up doesn't include `reasoning_content` (even empty).
+ // The v6 SDK's content-side path skips emission when reasoning
+ // is empty (see `dist/index.mjs:245`); our normalisation routes
+ // it via providerOptions instead, which fires unconditionally.
+ const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
+ agent.messages.push(
+ { role: "user", chunks: [{ type: "text", text: "ping" }] },
+ {
+ role: "assistant",
+ chunks: [
+ // Empty thinking — captured but produced no actual text.
+ { type: "thinking", text: "" },
+ { type: "text", text: "answer" },
+ ],
+ },
+ );
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("follow-up")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ content: unknown;
+ providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
+ }>;
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+ if (!assistantMsg) throw new Error("expected assistant message");
+
+ // The empty-string reasoning_content is explicitly set on
+ // providerOptions. (`""` is intentional and required —
+ // `assistantMsg.providerOptions?.openaiCompatible?.reasoning_content`
+ // must not be `undefined`.)
+ const rc = assistantMsg.providerOptions?.openaiCompatible?.reasoning_content;
+ expect(rc).toBeDefined();
+ expect(rc).toBe("");
+ });
+
+ it("openai-compatible normalisation does NOT run for messages without any reasoning parts", async () => {
+ // DeepSeek only requires `reasoning_content` AFTER a thinking
+ // turn. For purely-text assistant messages, we should leave
+ // providerOptions alone.
+ const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
+ agent.messages.push(
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ {
+ role: "assistant",
+ chunks: [{ type: "text", text: "hello back" }],
+ },
+ );
+
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ for await (const _ of agent.run("again")) {
+ /* consume */
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{
+ role: string;
+ providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
+ }>;
+ const assistantMsg = messages.find((m) => m.role === "assistant");
+
+ // No reasoning chunks → no providerOptions injection. (May still
+ // be undefined entirely if nothing else set it.)
+ const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content;
+ expect(rc).toBeUndefined();
+ });
});
diff --git a/packages/core/tests/chunks/append.test.ts b/packages/core/tests/chunks/append.test.ts
index c8917c9..dc277d2 100644
--- a/packages/core/tests/chunks/append.test.ts
+++ b/packages/core/tests/chunks/append.test.ts
@@ -6,6 +6,10 @@ import type { AgentEvent, ChatMessage, Chunk } from "../../src/types/index.js";
const td = (delta: string): AgentEvent => ({ type: "text-delta", delta });
const rd = (delta: string): AgentEvent => ({ type: "reasoning-delta", delta });
+const re = (metadata?: Record<string, unknown>): AgentEvent => ({
+ type: "reasoning-end",
+ ...(metadata !== undefined ? { metadata } : {}),
+});
const tc = (id: string, name = "fake_tool", args: Record<string, unknown> = {}): AgentEvent => ({
type: "tool-call",
toolCall: { id, name, arguments: args },
@@ -295,6 +299,99 @@ describe("appendEventToChunks — transition matrix", () => {
]);
});
+ // ─── reasoning-end (v6 SDK metadata round-trip) ──────────────────
+
+ it("reasoning-delta then reasoning-end seals the thinking chunk with metadata", () => {
+ const meta = { anthropic: { signature: "sig-1" } };
+ const chunks = run([rd("plan"), re(meta)]);
+ expect(chunks).toEqual([{ type: "thinking", text: "plan", metadata: meta }]);
+ });
+
+ it("two reasoning-deltas then reasoning-end coalesces text and seals once", () => {
+ const meta = { anthropic: { signature: "abc" } };
+ const chunks = run([rd("a"), rd("b"), re(meta)]);
+ expect(chunks).toEqual([{ type: "thinking", text: "ab", metadata: meta }]);
+ });
+
+ it("reasoning-delta → reasoning-end → reasoning-delta opens a NEW chunk", () => {
+ // Each Anthropic thinking content block gets its own metadata.
+ // Extending a sealed chunk would corrupt the text/metadata mapping.
+ const meta1 = { anthropic: { signature: "sig-1" } };
+ const chunks = run([rd("first"), re(meta1), rd("second")]);
+ expect(chunks).toEqual([
+ { type: "thinking", text: "first", metadata: meta1 },
+ { type: "thinking", text: "second" },
+ ]);
+ });
+
+ it("rd → re → rd → re produces two independently sealed chunks", () => {
+ const m1 = { anthropic: { signature: "s1" } };
+ const m2 = { anthropic: { signature: "s2" } };
+ const chunks = run([rd("first"), re(m1), rd("second"), re(m2)]);
+ expect(chunks).toEqual([
+ { type: "thinking", text: "first", metadata: m1 },
+ { type: "thinking", text: "second", metadata: m2 },
+ ]);
+ });
+
+ it("orphan reasoning-end (no prior thinking chunk) is a no-op", () => {
+ const chunks = run([re({ anthropic: { signature: "orphan" } })]);
+ expect(chunks).toEqual([]);
+ });
+
+ it("reasoning-end after an already-sealed thinking chunk does NOT overwrite", () => {
+ const m1 = { anthropic: { signature: "first" } };
+ const m2 = { anthropic: { signature: "second" } };
+ const chunks = run([rd("a"), re(m1), re(m2)]);
+ expect(chunks).toEqual([{ type: "thinking", text: "a", metadata: m1 }]);
+ });
+
+ it("reasoning-end without metadata is a silent no-op (does not seal)", () => {
+ // v6 may emit reasoning-end with no providerMetadata for
+ // non-Anthropic providers. Don't seal those chunks — a subsequent
+ // reasoning-delta should continue extending.
+ const chunks = run([rd("hello"), re(), rd(" world")]);
+ expect(chunks).toEqual([{ type: "thinking", text: "hello world" }]);
+ });
+
+ it("re walks back across an intervening text chunk to seal the right thinking", () => {
+ // Defensive: even if a non-thinking chunk lands between the
+ // reasoning text and its end-event, the metadata still attaches
+ // to the unsealed thinking chunk.
+ const meta = { anthropic: { signature: "late" } };
+ const chunks = run([rd("plan"), td("midstream"), re(meta)]);
+ expect(chunks).toEqual([
+ { type: "thinking", text: "plan", metadata: meta },
+ { type: "text", text: "midstream" },
+ ]);
+ });
+
+ it("interleaved rd / re / tool-call / rd / re produces correct chunk sequence", () => {
+ // Anthropic's interleaved-thinking emits a thinking block, then
+ // a tool call, then another thinking block. Each thinking block
+ // gets its own metadata.
+ const m1 = { anthropic: { signature: "before-tool" } };
+ const m2 = { anthropic: { signature: "after-tool" } };
+ const chunks = run([
+ rd("plan tool"),
+ re(m1),
+ tc("t1", "read_file"),
+ rd("plan response"),
+ re(m2),
+ ]);
+ expect(chunks.map((c) => c.type)).toEqual(["thinking", "tool-batch", "thinking"]);
+ expect(chunks[0]).toEqual({
+ type: "thinking",
+ text: "plan tool",
+ metadata: m1,
+ });
+ expect(chunks[2]).toEqual({
+ type: "thinking",
+ text: "plan response",
+ metadata: m2,
+ });
+ });
+
it("non-content events (status / done / task-list-update / message-queued etc.) are no-ops", () => {
const chunks = run([
td("hello"),
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
index fb5dca5..6171e6b 100644
--- a/packages/core/tests/llm/provider.test.ts
+++ b/packages/core/tests/llm/provider.test.ts
@@ -1,298 +1,178 @@
import { describe, expect, it, vi } from "vitest";
-// We test normalizeMessages through the middleware by mocking the provider
-// layers and capturing what transformParams does to the prompt.
-
-// Mock wrapLanguageModel to capture the middleware
-vi.mock("ai", async () => {
- const actual = await import("ai");
- return {
- ...actual,
- wrapLanguageModel: vi.fn(({ model, middleware }) => {
- // Return a wrapper that exposes the middleware for testing
- const wrapped = actual.wrapLanguageModel({ model, middleware });
- (wrapped as unknown as Record<string, unknown>)._middleware = middleware;
- return wrapped;
- }),
- streamText: vi.fn(),
- };
-});
+// Mock @ai-sdk/anthropic to capture what options createAnthropic is called with
+const mockAnthropicInstance = vi.fn((modelId: string) => ({
+ specificationVersion: "v3" as const,
+ provider: "anthropic.messages",
+ modelId,
+ supportedUrls: {},
+ doGenerate: vi.fn(),
+ doStream: vi.fn(),
+}));
+const mockCreateAnthropic = vi.fn(() => mockAnthropicInstance);
+vi.mock("@ai-sdk/anthropic", () => ({
+ createAnthropic: mockCreateAnthropic,
+}));
-// Mock provider factory
+// Mock @ai-sdk/openai-compatible to capture what options createOpenAICompatible
+// is called with, and what model id the returned factory is called with.
+const mockOpenAICompatibleFactory = vi.fn((modelId: string) => ({
+ specificationVersion: "v3" as const,
+ provider: "openai-compatible",
+ modelId,
+ supportedUrls: {},
+ doGenerate: vi.fn(),
+ doStream: vi.fn(),
+}));
+const mockCreateOpenAICompatible = vi.fn(() => mockOpenAICompatibleFactory);
vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: vi.fn(() => (modelId: string) => ({
- id: `mock-${modelId}`,
- doGenerate: vi.fn(),
- doStream: vi.fn(),
- })),
+ createOpenAICompatible: mockCreateOpenAICompatible,
}));
const { createProvider } = await import("../../src/llm/provider.js");
-// A helper that runs the middleware's transformParams on a prompt
-// and returns the resulting normalized prompt.
-async function runTransform(prompt: unknown[]): Promise<unknown[]> {
- const wrappedModel = createProvider({
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- })("test-model");
-
- const middleware = (
- wrappedModel as unknown as {
- _middleware: Array<{
- transformParams: (args: {
- type: string;
- params: Record<string, unknown>;
- }) => Promise<unknown>;
- }>;
- }
- )._middleware;
-
- const result = await middleware[0]?.transformParams({
- type: "stream",
- params: { prompt },
- });
-
- return (result as Record<string, unknown>).prompt as unknown[];
-}
+describe("createProvider (default OpenAI-compatible path)", () => {
+ it("does not wrap the model in a middleware layer — v6 SDK handles reasoning round-trip natively", () => {
+ mockCreateOpenAICompatible.mockClear();
+ mockOpenAICompatibleFactory.mockClear();
-describe("createProvider middleware", () => {
- it("passes through non-stream calls unchanged", async () => {
- const wrappedModel = createProvider({
+ const model = createProvider({
apiKey: "test-key",
baseURL: "https://example.com/v1",
- })("test-model");
-
- const middleware = (
- wrappedModel as unknown as {
- _middleware: Array<{
- transformParams: (args: {
- type: string;
- params: Record<string, unknown>;
- }) => Promise<unknown>;
- }>;
- }
- )._middleware;
-
- const params = { prompt: [], temperature: 0.5 };
- const result = (await middleware[0]?.transformParams({
- type: "generate",
- params,
- })) as Record<string, unknown>;
-
- expect(result).toEqual(params);
+ })("deepseek-v4-pro");
+
+ // The factory should have been invoked with the model id directly,
+ // without going through `wrapLanguageModel`. If a middleware were
+ // still in place, the returned object would carry an `_middleware`
+ // property (set by our test mock pattern). The bare provider model
+ // has no such property — verifying the v4-era normalizeMessages
+ // middleware is gone.
+ expect(mockOpenAICompatibleFactory).toHaveBeenCalledWith("deepseek-v4-pro");
+ expect((model as { _middleware?: unknown })._middleware).toBeUndefined();
});
- it("strips reasoning parts and sets reasoning_content on providerMetadata", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "I should use the list_files tool." },
- { type: "text", text: "Let me check the directory." },
- {
- type: "tool-call",
- toolCallId: "call_1",
- toolName: "list_files",
- args: { path: "." },
- },
- ],
- },
- ];
-
- const normalized = await runTransform(prompt);
- const msg = normalized[0] as Record<string, unknown>;
-
- // Reasoning parts removed from content
- const content = msg.content as Array<Record<string, unknown>>;
- expect(content).toHaveLength(2);
- expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
- expect(content.find((p) => p.type === "text")).toBeDefined();
- expect(content.find((p) => p.type === "tool-call")).toBeDefined();
-
- // reasoning_content set on providerMetadata
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
- expect(compat.reasoning_content).toBe("I should use the list_files tool.");
- });
-
- it("sets empty reasoning_content when no reasoning parts exist", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [{ type: "text", text: "Hello!" }],
- },
- ];
-
- const normalized = await runTransform(prompt);
- const msg = normalized[0] as Record<string, unknown>;
+ it("passes name, apiKey, baseURL to createOpenAICompatible", () => {
+ mockCreateOpenAICompatible.mockClear();
- // Content unchanged
- const content = msg.content as Array<Record<string, unknown>>;
- expect(content).toHaveLength(1);
- expect(content[0]?.type).toBe("text");
+ createProvider({
+ apiKey: "zen-key",
+ baseURL: "https://opencode.ai/zen/v1",
+ })("deepseek-v4-pro");
- // reasoning_content always set, even empty
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
- expect(compat.reasoning_content).toBe("");
+ expect(mockCreateOpenAICompatible).toHaveBeenCalledWith({
+ name: "opencode-zen",
+ apiKey: "zen-key",
+ baseURL: "https://opencode.ai/zen/v1",
+ });
});
+});
- it("does not modify user messages", async () => {
- const prompt = [
- {
- role: "user",
- content: [{ type: "text", text: "What dir am I in?" }],
- },
- ];
-
- const normalized = await runTransform(prompt);
- expect(normalized).toEqual(prompt);
+describe("createClaudeOAuthProvider", () => {
+ it("passes authToken (not apiKey) to createAnthropic for OAuth flow", () => {
+ mockCreateAnthropic.mockClear();
+
+ createProvider({
+ provider: "anthropic",
+ apiKey: "fallback-api-key",
+ baseURL: "",
+ claudeCredentials: { accessToken: "oauth-access-token" },
+ })("claude-opus-4-5");
+
+ expect(mockCreateAnthropic).toHaveBeenCalledOnce();
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
+ expect(callArgs.authToken).toBe("oauth-access-token");
+ expect(callArgs.apiKey).toBeUndefined();
});
- it("does not modify system messages", async () => {
- const prompt = [{ role: "system", content: "You are a helpful assistant." }];
+ it("falls back to apiKey as authToken when claudeCredentials are absent", () => {
+ mockCreateAnthropic.mockClear();
- const normalized = await runTransform(prompt);
- expect(normalized).toEqual(prompt);
- });
+ createProvider({
+ provider: "anthropic",
+ apiKey: "sk-ant-api-key",
+ baseURL: "",
+ })("claude-opus-4-5");
- it("handles assistant with plain string content (not array)", async () => {
- const prompt = [
- {
- role: "assistant",
- content: "Hello world",
- },
- ];
-
- const normalized = await runTransform(prompt);
- expect(normalized).toEqual(prompt);
+ expect(mockCreateAnthropic).toHaveBeenCalledOnce();
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
+ expect(callArgs.authToken).toBe("sk-ant-api-key");
+ expect(callArgs.apiKey).toBeUndefined();
});
- it("handles redacted-reasoning type parts", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [
- { type: "redacted-reasoning", text: "[redacted chain of thought]" },
- { type: "text", text: "Here is the result." },
- ],
- },
- ];
-
- const normalized = await runTransform(prompt);
- const msg = normalized[0] as Record<string, unknown>;
+ it("includes required Claude CLI headers", () => {
+ mockCreateAnthropic.mockClear();
- const content = msg.content as Array<Record<string, unknown>>;
- expect(content.find((p) => p.type === "redacted-reasoning")).toBeUndefined();
- expect(content.find((p) => p.type === "text")).toBeDefined();
+ createProvider({
+ provider: "anthropic",
+ apiKey: "test-key",
+ baseURL: "",
+ claudeCredentials: { accessToken: "tok" },
+ })("claude-opus-4-5");
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
- expect(compat.reasoning_content).toBe("[redacted chain of thought]");
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
+ string,
+ Record<string, string>
+ >;
+ expect(callArgs.headers?.["anthropic-dangerous-direct-browser-access"]).toBe("true");
+ expect(callArgs.headers?.["x-app"]).toBe("cli");
+ expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/);
});
- it("preserves existing providerMetadata fields", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "thinking..." },
- { type: "text", text: "done" },
- ],
- providerMetadata: {
- openaiCompatible: { custom_field: "keep-me" },
- },
- },
- ];
+ it("uses default Anthropic baseURL when none provided", () => {
+ mockCreateAnthropic.mockClear();
- const normalized = await runTransform(prompt);
- const msg = normalized[0] as Record<string, unknown>;
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
+ createProvider({
+ provider: "anthropic",
+ apiKey: "test-key",
+ baseURL: "",
+ claudeCredentials: { accessToken: "tok" },
+ })("claude-opus-4-5");
- expect(compat.custom_field).toBe("keep-me");
- expect(compat.reasoning_content).toBe("thinking...");
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
+ expect(callArgs.baseURL).toBe("https://api.anthropic.com/v1");
});
- it("handles multi-message prompts with mixed roles", async () => {
- const prompt = [
- { role: "system", content: "Be helpful." },
- { role: "user", content: [{ type: "text", text: "hi" }] },
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "I'll say hello." },
- { type: "text", text: "Hi there!" },
- ],
- },
- ];
+ it("uses configured baseURL when provided", () => {
+ mockCreateAnthropic.mockClear();
- const normalized = await runTransform(prompt);
-
- // System and user unchanged
- expect(normalized[0]).toEqual(prompt[0]);
- expect(normalized[1]).toEqual(prompt[1]);
+ createProvider({
+ provider: "anthropic",
+ apiKey: "test-key",
+ baseURL: "https://custom.proxy.example.com/v1",
+ claudeCredentials: { accessToken: "tok" },
+ })("claude-opus-4-5");
- // Assistant transformed
- const msg = normalized[2] as Record<string, unknown>;
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
- expect(compat.reasoning_content).toBe("I'll say hello.");
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
+ expect(callArgs.baseURL).toBe("https://custom.proxy.example.com/v1");
});
+});
- it("concatenates multiple reasoning parts", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "Step 1: " },
- { type: "reasoning", text: "Step 2: " },
- { type: "reasoning", text: "Step 3." },
- { type: "text", text: "Final answer." },
- ],
- },
- ];
+describe("createApiKeyAnthropicProvider", () => {
+ it("passes apiKey (not authToken) to createAnthropic", () => {
+ mockCreateAnthropic.mockClear();
- const normalized = await runTransform(prompt);
- const msg = normalized[0] as Record<string, unknown>;
- const pm = msg.providerMetadata as Record<string, unknown>;
- const compat = pm.openaiCompatible as Record<string, unknown>;
- expect(compat.reasoning_content).toBe("Step 1: Step 2: Step 3.");
+ createProvider({
+ provider: "opencode-anthropic",
+ apiKey: "zen-api-key",
+ baseURL: "",
+ })("minimax-model");
+
+ expect(mockCreateAnthropic).toHaveBeenCalledOnce();
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
+ expect(callArgs.apiKey).toBe("zen-api-key");
+ expect(callArgs.authToken).toBeUndefined();
});
- it("applies to every assistant message in a multi-step history", async () => {
- const prompt = [
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "First thought." },
- { type: "tool-call", toolCallId: "c1", toolName: "list_files", args: {} },
- ],
- },
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "Second thought." },
- { type: "text", text: "All done." },
- ],
- },
- ];
+ it("uses default OpenCode Zen baseURL when none provided", () => {
+ mockCreateAnthropic.mockClear();
- const normalized = await runTransform(prompt);
+ createProvider({
+ provider: "opencode-anthropic",
+ apiKey: "zen-api-key",
+ baseURL: "",
+ })("minimax-model");
- const msg1 = normalized[0] as Record<string, unknown>;
- const compat1 = (msg1.providerMetadata as Record<string, unknown>).openaiCompatible as Record<
- string,
- unknown
- >;
- expect(compat1.reasoning_content).toBe("First thought.");
-
- const msg2 = normalized[1] as Record<string, unknown>;
- const compat2 = (msg2.providerMetadata as Record<string, unknown>).openaiCompatible as Record<
- string,
- unknown
- >;
- expect(compat2.reasoning_content).toBe("Second thought.");
+ const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
+ expect(callArgs.baseURL).toBe("https://opencode.ai/zen/go/v1");
});
});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
index b6f1fca..cad75d2 100644
--- a/packages/core/tests/tools/registry.test.ts
+++ b/packages/core/tests/tools/registry.test.ts
@@ -17,6 +17,21 @@ const anotherTool: ToolDefinition = {
execute: async (_args) => "another result",
};
+/** A non-trivial tool that exercises nested objects, required fields, and enums. */
+const complexTool: ToolDefinition = {
+ name: "complex_tool",
+ description: "A tool with nested parameters",
+ parameters: z.object({
+ command: z.string().describe("Shell command to run"),
+ options: z.object({
+ timeout: z.number().optional().describe("Timeout in milliseconds"),
+ shell: z.enum(["bash", "sh", "zsh"]).describe("Shell to use"),
+ }),
+ flags: z.array(z.string()).optional().describe("Additional flags"),
+ }),
+ execute: async (_args) => "complex result",
+};
+
describe("createToolRegistry", () => {
it("returns all tools via getTools()", () => {
const registry = createToolRegistry([mockTool, anotherTool]);
@@ -38,13 +53,91 @@ describe("createToolRegistry", () => {
expect(registry.getTool("nonexistent")).toBeUndefined();
});
- it("getAISDKTools returns correct format", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools).toHaveProperty("mock_tool");
- expect(aiTools).toHaveProperty("another_tool");
- // Each should have description and parameters (AI SDK tool format)
- expect(aiTools.mock_tool).toHaveProperty("description");
- expect(aiTools.mock_tool).toHaveProperty("parameters");
+ describe("getAISDKTools", () => {
+ it("returns correct keys for all tools", () => {
+ const registry = createToolRegistry([mockTool, anotherTool]);
+ const aiTools = registry.getAISDKTools();
+ expect(aiTools).toHaveProperty("mock_tool");
+ expect(aiTools).toHaveProperty("another_tool");
+ });
+
+ it("AI SDK tools have description from ToolDefinition", () => {
+ const registry = createToolRegistry([mockTool]);
+ const aiTools = registry.getAISDKTools();
+ expect(aiTools.mock_tool.description).toBe("A mock tool for testing");
+ });
+
+ it("AI SDK tools surface schema via inputSchema, not parameters", () => {
+ const registry = createToolRegistry([mockTool]);
+ const aiTools = registry.getAISDKTools();
+ // v6 uses inputSchema; v4 used parameters — this verifies the migration
+ expect(aiTools.mock_tool).toHaveProperty("inputSchema");
+ expect(aiTools.mock_tool).not.toHaveProperty("parameters");
+ });
+
+ it("AI SDK tools have no execute callback so the SDK does not auto-run", () => {
+ const registry = createToolRegistry([mockTool, anotherTool, complexTool]);
+ const aiTools = registry.getAISDKTools();
+ for (const [name, sdkTool] of Object.entries(aiTools)) {
+ expect(
+ (sdkTool as Record<string, unknown>).execute,
+ `Tool "${name}" should not have an execute callback`,
+ ).toBeUndefined();
+ }
+ });
+
+ it("inputSchema produces valid JSONSchema7 for a simple tool", () => {
+ const registry = createToolRegistry([mockTool]);
+ const aiTools = registry.getAISDKTools();
+ const schema = aiTools.mock_tool.inputSchema;
+ // jsonSchema() wraps the raw JSONSchema7; it should expose the schema
+ // as a `jsonSchema` property on the Schema object
+ expect(schema).toBeDefined();
+ // The wrapped schema object should carry the JSON Schema definition
+ const schemaObj = schema as { jsonSchema: Record<string, unknown> };
+ expect(schemaObj.jsonSchema).toBeDefined();
+ expect(schemaObj.jsonSchema.type).toBe("object");
+ const props = schemaObj.jsonSchema.properties as Record<string, unknown>;
+ expect(props).toHaveProperty("input");
+ });
+
+ it("inputSchema produces correct JSONSchema7 for a non-trivial nested tool", () => {
+ const registry = createToolRegistry([complexTool]);
+ const aiTools = registry.getAISDKTools();
+ const schema = aiTools.complex_tool.inputSchema;
+ expect(schema).toBeDefined();
+ const schemaObj = schema as { jsonSchema: Record<string, unknown> };
+ expect(schemaObj.jsonSchema.type).toBe("object");
+
+ const props = schemaObj.jsonSchema.properties as Record<string, Record<string, unknown>>;
+
+ // Top-level required field "command"
+ expect(props).toHaveProperty("command");
+ expect(props.command.type).toBe("string");
+
+ // Nested object "options"
+ expect(props).toHaveProperty("options");
+ expect(props.options.type).toBe("object");
+ const optProps = props.options.properties as Record<string, Record<string, unknown>>;
+ expect(optProps).toHaveProperty("shell");
+ expect(optProps.shell.enum).toEqual(["bash", "sh", "zsh"]);
+
+ // Optional array "flags" present as a property
+ expect(props).toHaveProperty("flags");
+ expect(props.flags.type).toBe("array");
+
+ // Required fields should include "command" and "options"
+ const required = schemaObj.jsonSchema.required as string[];
+ expect(required).toContain("command");
+ expect(required).toContain("options");
+ });
+
+ it("getTool still returns the original ToolDefinition with execute", () => {
+ const registry = createToolRegistry([mockTool]);
+ const def = registry.getTool("mock_tool");
+ expect(def).toBeDefined();
+ expect(typeof def?.execute).toBe("function");
+ expect(def?.name).toBe("mock_tool");
+ });
});
});
diff --git a/packages/frontend/serve.ts b/packages/frontend/serve.ts
index 75b0a9f..020b698 100644
--- a/packages/frontend/serve.ts
+++ b/packages/frontend/serve.ts
@@ -29,7 +29,9 @@ const distDir = resolve(process.env.DIST_DIR || join(moduleDir, "dist"));
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
console.error(`[dispatch-frontend] dist directory not found: ${distDir}`);
- console.error("[dispatch-frontend] Build the frontend first (bun run --cwd packages/frontend build)");
+ console.error(
+ "[dispatch-frontend] Build the frontend first (bun run --cwd packages/frontend build)",
+ );
process.exit(1);
}
@@ -38,7 +40,7 @@ const indexPath = join(distDir, "index.html");
function safeResolve(urlPath: string): string | null {
// Normalize and prevent path-traversal outside distDir.
const decoded = decodeURIComponent(urlPath);
- const candidate = resolve(distDir, "." + normalize(decoded));
+ const candidate = resolve(distDir, `.${normalize(decoded)}`);
if (!candidate.startsWith(distDir)) return null;
return candidate;
}
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 0c85349..54e99c8 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -36,6 +36,42 @@ const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
"config-reload": "Config reload",
cancelled: "Cancelled",
};
+
+/**
+ * Returns true if the given chunk has visible content worth rendering.
+ * Used by `hasRenderableContent` to suppress empty assistant bubbles.
+ *
+ * Note: `ThinkingChunk.metadata` is intentionally excluded — it is
+ * internal wire data (Anthropic's providerMetadata / signature) and
+ * must never appear in the UI.
+ */
+function chunkHasRenderableContent(chunk: Chunk): boolean {
+ switch (chunk.type) {
+ case "text":
+ return chunk.text.length > 0;
+ case "thinking":
+ return chunk.text.length > 0;
+ case "tool-batch":
+ return chunk.calls.length > 0;
+ case "error":
+ return true;
+ case "system":
+ return true;
+ }
+}
+
+/**
+ * True when the assistant bubble has something worth showing.
+ * Guards the assistant render path so we don't emit an empty box
+ * (e.g. a message that only had empty/signature-only thinking blocks
+ * from Anthropic adaptive thinking mode).
+ *
+ * Streaming messages always have renderable content — the cursor
+ * needs somewhere to live.
+ */
+const hasRenderableContent = $derived(
+ message.isStreaming === true || message.chunks.some(chunkHasRenderableContent),
+);
</script>
{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)}
@@ -43,13 +79,18 @@ const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
{#if chunk.type === "text"}
<MarkdownRenderer text={chunk.text} {streaming} />
{:else if chunk.type === "thinking"}
- <div class="collapse collapse-arrow mb-2 p-1">
- <input type="checkbox" checked={appSettings.autoExpandThinking} />
- <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
- <div class="collapse-content text-sm opacity-60 italic p-0">
- <p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
+ <!-- Skip empty thinking chunks: Anthropic adaptive thinking can emit
+ a reasoning-end with a signature but no thinking_delta content.
+ The metadata is internal wire data — never displayed. -->
+ {#if chunk.text.length > 0}
+ <div class="collapse collapse-arrow mb-2 p-1">
+ <input type="checkbox" checked={appSettings.autoExpandThinking} />
+ <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
+ <div class="collapse-content text-sm opacity-60 italic p-0">
+ <p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
+ </div>
</div>
- </div>
+ {/if}
{:else if chunk.type === "tool-batch"}
{#each chunk.calls as call (call.id)}
<ToolCallDisplay toolCall={call} />
@@ -78,6 +119,11 @@ const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
{@render renderChunks(message.chunks, false)}
</div>
</div>
+{:else if !isUser && !hasRenderableContent}
+ <!-- Empty assistant message — no renderable chunks and not streaming.
+ Suppressed to avoid an empty bubble (e.g. a turn that produced
+ only empty/signature-only thinking blocks from Anthropic adaptive
+ thinking mode, or a done event with no content). -->
{:else}
<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
<div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index b07d37a..1d60e0b 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -445,6 +445,7 @@ export function createTabStore() {
break;
}
case "reasoning-delta":
+ case "reasoning-end":
case "text-delta":
case "tool-call":
case "tool-result":
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 1043f64..6051810 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -28,6 +28,14 @@ export interface TextChunk {
export interface ThinkingChunk {
type: "thinking";
text: string;
+ /**
+ * Mirror of core. Anthropic's `providerMetadata` blob captured from
+ * the v6 `reasoning-end` stream event. Present once the backend has
+ * sealed the chunk; absent for in-flight thinking or for non-Anthropic
+ * models. The UI doesn't render this — it lives here for wire-format
+ * symmetry with the persisted chunk shape.
+ */
+ metadata?: Record<string, unknown>;
}
export interface ToolBatchChunk {
@@ -77,6 +85,7 @@ export type AgentEvent =
| { type: "statuses"; statuses: Record<string, "idle" | "running" | "error"> }
| { type: "text-delta"; delta: string }
| { type: "reasoning-delta"; delta: string }
+ | { type: "reasoning-end"; metadata?: Record<string, unknown> }
| {
type: "tool-call";
toolCall: {
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index e3a802b..47a6c97 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -230,6 +230,62 @@ describe("tabStore — streaming chunk flow (real $state)", () => {
]);
});
+ it("reasoning-end seals the thinking chunk with metadata (end-to-end signature seal)", async () => {
+ const { store, tabId } = await setupStoreWithTab();
+
+ store.handleEvent({ type: "reasoning-delta", delta: "plan", tabId });
+ store.handleEvent({
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "wire-sig" } },
+ tabId,
+ });
+
+ const chunks = getAssistantChunks(store);
+ expect(chunks).toEqual([
+ { type: "thinking", text: "plan", metadata: { anthropic: { signature: "wire-sig" } } },
+ ]);
+ });
+
+ it("reasoning-delta after reasoning-end opens a new thinking chunk (v6 multi-block)", async () => {
+ const { store, tabId } = await setupStoreWithTab();
+
+ // First thinking block: delta → seal
+ store.handleEvent({ type: "reasoning-delta", delta: "block-1", tabId });
+ store.handleEvent({
+ type: "reasoning-end",
+ metadata: { anthropic: { signature: "sig-1" } },
+ tabId,
+ });
+
+ // Second thinking block: new delta after seal must NOT extend the sealed chunk
+ store.handleEvent({ type: "reasoning-delta", delta: "block-2", tabId });
+
+ const chunks = getAssistantChunks(store);
+ expect(chunks).toHaveLength(2);
+ // First chunk sealed — text must not have been extended
+ expect(chunks?.[0]).toEqual({
+ type: "thinking",
+ text: "block-1",
+ metadata: { anthropic: { signature: "sig-1" } },
+ });
+ // Second chunk is fresh — no metadata yet
+ expect(chunks?.[1]).toEqual({ type: "thinking", text: "block-2" });
+ });
+
+ it("reasoning-end without metadata is a no-op (subsequent delta still extends the chunk)", async () => {
+ const { store, tabId } = await setupStoreWithTab();
+
+ store.handleEvent({ type: "reasoning-delta", delta: "start", tabId });
+ // reasoning-end with no metadata → helper returns early, chunk stays unsealed
+ store.handleEvent({ type: "reasoning-end", tabId });
+ // Next delta should extend the existing (still unsealed) chunk
+ store.handleEvent({ type: "reasoning-delta", delta: " continued", tabId });
+
+ const chunks = getAssistantChunks(store);
+ expect(chunks).toHaveLength(1);
+ expect(chunks?.[0]).toEqual({ type: "thinking", text: "start continued" });
+ });
+
it("interleaved think→text→think yields three chunks in order", async () => {
const { store, tabId } = await setupStoreWithTab();
store.handleEvent({ type: "reasoning-delta", delta: "thinking-1", tabId });
diff --git a/plan-v6-upgrade.md b/plan-v6-upgrade.md
new file mode 100644
index 0000000..ad3d223
--- /dev/null
+++ b/plan-v6-upgrade.md
@@ -0,0 +1,450 @@
+# AI SDK v4 → v6 Upgrade Plan
+
+## Why
+
+The `reasoning-signature without reasoning` bug we've been fighting is a v4
+SDK artefact:
+
+ - `@ai-sdk/[email protected]` emits Anthropic's `signature_delta` as a
+ separate stream chunk (`reasoning-signature`). The SDK accumulator
+ requires every signature to follow a `reasoning` chunk; Anthropic's
+ adaptive thinking happily emits empty thinking blocks (signature only,
+ no `thinking_delta`), which trips an `InvalidStreamPartError`.
+ - Sending a thinking block back to Anthropic without its signature is
+ rejected. Our chunk store had no signature field, so the round-trip
+ failed.
+
+Both classes of bug are **gone in v6** because:
+
+ - `@ai-sdk/[email protected]` packages Anthropic's signature inside
+ `providerMetadata` on the `reasoning-end` stream event. There is no
+ separate `reasoning-signature` chunk to orphan.
+ - `providerOptions` on a v6 `ReasoningPart` carries the metadata back
+ verbatim, so the round-trip Just Works.
+ - v6 `@ai-sdk/[email protected]` natively supports
+ `providerOptions.anthropic.thinking = { type: "adaptive" }` — the
+ `rewriteBodyForOpus47` custom-fetch hack disappears.
+ - v6 `createAnthropic({ authToken })` natively supports OAuth Bearer
+ auth — the `customFetch` that swaps `x-api-key` for `Authorization:
+ Bearer` also disappears.
+
+The opencode reference at `../opencode-patch/opencode/` runs the same
+extended-thinking flow on v6 with none of the workarounds we've been
+piling onto v4.
+
+## Versions
+
+| Package | v4 (current) | v6 (target) |
+|---|---|---|
+| `ai` | `4.3.19` | `6.0.191` |
+| `@ai-sdk/anthropic` | `1.2.12` | `3.0.79` |
+| `@ai-sdk/openai-compatible` | `0.2.x` | `2.0.48` |
+| `@ai-sdk/provider` | `1.1.3` | `3.0.10` |
+| `@ai-sdk/provider-utils` | `2.2.8` | `4.0.27` |
+| Middleware spec | `v1` | `v3` |
+
+`packages/core/package.json` already bumped; `bun install` already done.
+
+## What we keep from the current codebase
+
+- **The whole `Chunk` model.** `ChatMessage { chunks: Chunk[] }` stays as
+ the canonical persisted shape. Renderers, DB layer, and the frontend
+ store are unchanged in their architecture.
+- **The outer manual tool loop** in `Agent.run()`. We run tools ourselves
+ for permission prompts, shell-output streaming, and queued-message
+ injection. The SDK is given tools without `execute` so it never
+ auto-runs them.
+- **`appendEventToChunks` as the single source of event → chunk truth.**
+ Frontend store + backend agent both call into it.
+- **`createToolRegistry` indirection.** Our internal `ToolDefinition`
+ shape stays; only the registry's conversion to AI SDK tools changes.
+- **The agent-manager broadcaster.** `AgentEvent` shape changes (see
+ below); the broadcast plumbing does not.
+
+## What changes (high level)
+
+| Surface | v4 | v6 |
+|---|---|---|
+| Message type | `CoreMessage` | `ModelMessage` |
+| Streaming events | `text-delta` + `reasoning` + `reasoning-signature` + `tool-call` | `text-start/-delta/-end`, `reasoning-start/-delta/-end`, `tool-input-start/-delta/-end`, `tool-call`, `tool-result`, `tool-error`, `start-step`, `finish-step`, `start`, `finish`, `abort`, `error` |
+| Reasoning signature | separate `reasoning-signature` event | `providerMetadata` on `reasoning-end` |
+| Tool definition | `tool({ parameters: ZodSchema })` | `tool({ inputSchema: jsonSchema(...) })` |
+| Tool call payload | `args` | `input` |
+| Tool result payload | raw value | `ToolResultOutput` (`{ type: "text"; value: string }` etc.) |
+| Token budget | `maxTokens` | `maxOutputTokens` |
+| OAuth | custom fetch swapping `x-api-key` → `Authorization: Bearer` | `createAnthropic({ authToken })` |
+| Opus 4.7 thinking | body-rewrite injection of `thinking: { type: "adaptive" }` | `providerOptions.anthropic.thinking = { type: "adaptive" }` (native) |
+| Middleware spec | `"v1"`, `transformParams({ type, params })` returning params | `"v3"`, `transformParams({ type, params, model })` returning `LanguageModelV3CallOptions` |
+
+## Canonical types (locked)
+
+### Chunk (no breaking changes vs. current HEAD, signature added back)
+
+`packages/core/src/types/index.ts` and mirrored in
+`packages/frontend/src/lib/types.ts`:
+
+```ts
+export type Chunk =
+ | TextChunk
+ | ThinkingChunk
+ | ToolBatchChunk
+ | ErrorChunk
+ | SystemChunk;
+
+export interface TextChunk {
+ type: "text";
+ text: string;
+}
+
+export interface ThinkingChunk {
+ type: "thinking";
+ text: string;
+ /**
+ * Full Anthropic `providerMetadata` blob captured from the
+ * `reasoning-end` stream event. Round-tripped verbatim as
+ * `providerOptions` on the ReasoningPart in the next request so
+ * Anthropic can validate the thinking block's signature. Optional
+ * because non-Anthropic models don't produce one.
+ */
+ metadata?: Record<string, unknown>;
+}
+
+export interface ToolBatchChunk {
+ type: "tool-batch";
+ calls: ToolBatchEntry[];
+}
+
+export interface ToolBatchEntry {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ shellOutput?: { stdout: string; stderr: string };
+}
+
+export interface ErrorChunk {
+ type: "error";
+ message: string;
+ statusCode?: number;
+}
+
+export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
+
+export interface SystemChunk {
+ type: "system";
+ kind: SystemChunkKind;
+ text: string;
+}
+```
+
+### AgentEvent (one new variant, otherwise unchanged)
+
+```ts
+export type AgentEvent =
+ | { type: "status"; status: AgentStatus }
+ | { type: "text-delta"; delta: string }
+ | { type: "reasoning-delta"; delta: string }
+ // NEW: emitted on the v6 `reasoning-end` stream event when it carries
+ // providerMetadata. `appendEventToChunks` attaches the blob to the
+ // most recent unsealed thinking chunk; `toModelMessages` reads it back
+ // out as `providerOptions` on the ReasoningPart in the next request.
+ | { type: "reasoning-end"; metadata?: Record<string, unknown> }
+ | { type: "tool-call"; toolCall: ToolCall }
+ | { type: "tool-result"; toolResult: ToolResult }
+ | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
+ | { type: "error"; error: string; statusCode?: number }
+ | { type: "notice"; message: string }
+ | { type: "model-changed"; keyId: string; modelId: string }
+ | { type: "done"; message: ChatMessage }
+ | { type: "task-list-update"; tasks: TaskItem[] }
+ | { type: "config-reload" }
+ | { type: "tab-created"; id: string; title: string; keyId: string | null;
+ modelId: string | null; parentTabId: string | null;
+ workingDirectory: string | null }
+ | { type: "message-queued"; tabId: string; messageId: string; message: string }
+ | { type: "message-consumed"; tabId: string; messageIds: string[] }
+ | { type: "message-cancelled"; tabId: string; messageId: string };
+```
+
+### appendEventToChunks sealing semantics
+
+A `thinking` chunk is *sealed* once it has a `metadata` field. A
+`reasoning-delta` after a sealed chunk opens a new chunk. A
+`reasoning-end` walks back to the most recent unsealed chunk and
+attaches metadata. (Same shape as the current code; just renamed from
+the v4-era "signature".)
+
+```ts
+case "reasoning-delta": {
+ const last = chunks[chunks.length - 1];
+ if (last && last.type === "thinking" && last.metadata === undefined) {
+ last.text += event.delta;
+ } else {
+ chunks.push({ type: "thinking", text: event.delta });
+ }
+ return;
+}
+
+case "reasoning-end": {
+ if (event.metadata === undefined) return; // nothing to attach
+ for (let i = chunks.length - 1; i >= 0; i--) {
+ const c = chunks[i];
+ if (!c || c.type !== "thinking") continue;
+ if (c.metadata !== undefined) return; // already sealed
+ c.metadata = event.metadata;
+ return;
+ }
+ return; // orphan — drop
+}
+```
+
+## Stream event → AgentEvent mapping (agent.ts)
+
+| v6 fullStream chunk | AgentEvent we emit |
+|---|---|
+| `start` | none (ignored) |
+| `text-start { id }` | none |
+| `text-delta { id, text }` | `{ type: "text-delta", delta: text }` |
+| `text-end { id }` | none |
+| `reasoning-start { id }` | none |
+| `reasoning-delta { id, text }` | `{ type: "reasoning-delta", delta: text }` |
+| `reasoning-end { id, providerMetadata }` | `{ type: "reasoning-end", metadata: providerMetadata }` (only when metadata is present, otherwise none) |
+| `tool-input-start/-delta/-end` | none (we don't surface argument streaming to the UI) |
+| `tool-call { toolCallId, toolName, input }` | `{ type: "tool-call", toolCall: { id: toolCallId, name: toolName, arguments: input } }` |
+| `tool-result` | none — the SDK only emits this if the tool's `execute` ran. Our SDK tools have no `execute` (see below), so this never fires from the stream. The agent's manual executor emits `tool-result` events itself. |
+| `tool-error` | log + treat as an SDK-level error (defensive) |
+| `start-step` / `finish-step` | none (we manage steps ourselves) |
+| `abort { reason }` | `{ type: "error", error: "aborted: " + reason }` (defensive; we don't currently abort streams from agent.ts) |
+| `error { error }` | `{ type: "error", error: formatError(error, config), statusCode? }` |
+| `finish { finishReason, totalUsage }` | none (we break out of the step loop based on whether the step emitted any tool-call) |
+| `raw` | none |
+
+## toModelMessages (replaces v4 toCoreMessages)
+
+Same shape as today, with these changes:
+
+- Renamed: `toCoreMessages` → `toModelMessages`, return `ModelMessage[]`.
+- `ReasoningPart`:
+ ```ts
+ case "thinking":
+ parts.push({
+ type: "reasoning",
+ text: chunk.text,
+ ...(chunk.metadata !== undefined
+ ? { providerOptions: chunk.metadata as ProviderOptions }
+ : {}),
+ });
+ break;
+ ```
+ No "skip signatureless chunks" branch — even bare reasoning is legal
+ in v6 (it's just sent without providerOptions). Anthropic accepts that;
+ it just doesn't use the absent signature.
+
+- `ToolCallPart`:
+ ```ts
+ parts.push({
+ type: "tool-call",
+ toolCallId: entry.id,
+ toolName,
+ input: entry.arguments, // was: args
+ });
+ ```
+
+- `ToolResultPart`:
+ ```ts
+ result.push({
+ role: "tool",
+ content: [{
+ type: "tool-result",
+ toolCallId: tr.toolCallId,
+ toolName: tr.toolName,
+ output: { type: "text", value: tr.result }, // was: result
+ }],
+ });
+ ```
+
+- **Anthropic structural normalisations** (cribbed from opencode
+ `provider/transform.ts`):
+ 1. Filter out `text` / `reasoning` parts whose `text === ""`. Drop
+ messages whose content array becomes empty.
+ 2. For assistant messages whose content contains at least one
+ `tool-call` followed by a non-`tool-call` part, split into two
+ consecutive assistant messages: `[non-tool parts] + [tool-call parts]`.
+ Anthropic rejects `[tool_use, text]` ordering with
+ "`tool_use` ids were found without `tool_result` blocks immediately
+ after". opencode does this at `transform.ts:124-148`.
+
+ Both apply only when the active provider is Anthropic (Claude OAuth or
+ `opencode-anthropic`). Skip for OpenCode Zen / openai-compatible.
+
+## Provider (provider.ts)
+
+### Claude OAuth (anthropic provider)
+
+```ts
+import { createAnthropic } from "@ai-sdk/anthropic";
+
+function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
+ const anthropic = createAnthropic({
+ baseURL: config.baseURL || "https://api.anthropic.com/v1",
+ authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
+ headers: {
+ "anthropic-dangerous-direct-browser-access": "true",
+ "x-app": "cli",
+ "user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
+ },
+ });
+ return (modelId: string) => anthropic(modelId);
+}
+```
+
+- `authToken` replaces the v4 customFetch + apiKey-swap dance.
+- `rewriteBodyForOpus47` deleted. Opus 4.7 thinking is configured via
+ `providerOptions.anthropic.thinking = { type: "adaptive" }` in agent.ts.
+
+### Plain-API-key Anthropic (opencode-go MiniMax/Qwen)
+
+```ts
+function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory {
+ const anthropic = createAnthropic({
+ apiKey: config.apiKey,
+ baseURL: config.baseURL || "https://opencode.ai/zen/go/v1",
+ });
+ return (modelId: string) => anthropic(modelId);
+}
+```
+
+### OpenAI-compatible (default)
+
+`createOpenAICompatible` API is unchanged (`{ name, apiKey, baseURL }`).
+
+### Middleware (v3 spec) for `normalizeMessages` tweak
+
+OpenCode Go MiniMax/Qwen still need the reasoning-text-stripping
+middleware we have today. The v3 spec changes from v1:
+
+```ts
+const middleware: LanguageModelV3Middleware = {
+ specificationVersion: "v3" as const, // ← v3 spec replaces v4's "v1" — actual field name is `specificationVersion`
+ async transformParams({ type, params, model }) {
+ if (type === "stream" && params.prompt) {
+ return {
+ ...params,
+ prompt: normalizeMessages(params.prompt as unknown[]) as LanguageModelV3Prompt,
+ };
+ }
+ return params;
+ },
+};
+return wrapLanguageModel({ model: provider(modelId), middleware: [middleware] });
+```
+
+(`LanguageModelV3Middleware` type and `specificationVersion` field — re-exported as `LanguageModelMiddleware` from `ai`, and as `LanguageModelV3Middleware` from `@ai-sdk/[email protected]`.)
+
+## Tools (tools/registry.ts)
+
+v6 tool factory:
+
+```ts
+import { tool, jsonSchema } from "ai";
+import { z, type ZodTypeAny } from "zod";
+
+function zodToJsonSchema(schema: ZodTypeAny): unknown {
+ // tiny helper — most opencode/AI-SDK examples use a third-party
+ // zod-to-json-schema package, but for our simple parameter objects
+ // the AI SDK's `jsonSchema()` wrapper accepts raw JSONSchema7 directly.
+ // We can import zod-to-json-schema OR keep using our existing
+ // (zod-style) ToolDefinition.parameters as-is and pass through
+ // `tool({ inputSchema: <jsonSchemaOrZod> })`.
+ ...
+}
+
+function toAISDKTool(def: ToolDefinition) {
+ return tool({
+ description: def.description,
+ inputSchema: jsonSchema(zodToJsonSchema(def.parameters)),
+ // NO execute: we don't want the SDK to auto-run tools. We collect
+ // tool-call events from fullStream, run tools ourselves with
+ // permission/shell-output streaming, then push the results as a
+ // tool ModelMessage in the next streamText call.
+ });
+}
+```
+
+**Decision: use `zod` v4's built-in `z.toJSONSchema()` if available, else
+add `zod-to-json-schema` as a dep.** Our zod version is `^3.23.0`; we
+need to verify what's available. If neither, the Agent C task will
+include adding `zod-to-json-schema` as a dependency.
+
+## File ownership for parallel implementation
+
+### Phase 0 — me, sequential (load-bearing, no parallel work safe)
+
+| File | Change |
+|---|---|
+| `packages/core/src/types/index.ts` | Add `metadata?` to `ThinkingChunk`; add `reasoning-end` variant to `AgentEvent`; everything else unchanged |
+| `packages/core/src/chunks/append.ts` | Replace `signature` handling with `metadata`; add `reasoning-end` case; update jsdoc table |
+| `packages/core/tests/chunks/append.test.ts` | Replace `rs` helper with `re` (reasoning-end), exercise new sealing semantics + walk-back + orphan-drop |
+| `packages/frontend/src/lib/types.ts` | Mirror the `metadata` + `reasoning-end` additions |
+| `packages/frontend/src/lib/tabs.svelte.ts` | Add `reasoning-end` to the `applyChunkEvent` switch case |
+
+After Phase 0: `bun run --cwd packages/core test tests/chunks` MUST pass.
+Everything else will still be broken until Phase 1 lands.
+
+### Phase 1 — parallel sonnet agents, disjoint file ownership
+
+| Agent | Owns | Cannot touch |
+|---|---|---|
+| **A: agent** | `packages/core/src/agent/agent.ts`, `packages/core/tests/agent/agent.test.ts` | provider.ts, registry.ts, types.ts |
+| **B: provider** | `packages/core/src/llm/provider.ts`, `packages/core/tests/llm/provider.test.ts` | agent.ts, registry.ts |
+| **C: tools** | `packages/core/src/tools/registry.ts`, `packages/core/tests/tools/registry.test.ts` (only registry; do NOT touch individual tool files like `read-file.ts`), `packages/core/package.json` (may add `zod-to-json-schema` dep) | agent.ts, provider.ts |
+| **D: api** | `packages/api/src/agent-manager.ts`, `packages/api/tests/agent-manager.test.ts` | anything in core/src |
+| **E: frontend** | `packages/frontend/src/lib/components/ChatMessage.svelte`, `packages/frontend/tests/chat-store.test.ts` | tabs.svelte.ts (I already touched it in Phase 0); types.ts (also Phase 0) |
+
+`packages/core/src/credentials/claude.ts` — left alone. Its
+`buildBillingHeaderValue` and `SYSTEM_IDENTITY` are pure helpers; agent.ts
+still calls them.
+
+`packages/core/src/db/*` — left alone. The DB stores `chunks` as JSON;
+the only schema-relevant change (signature → metadata) is internal to
+the chunk shape and persists transparently.
+
+Individual tool implementations under `packages/core/src/tools/*.ts`
+(except `registry.ts`) — left alone. They follow the
+`ToolDefinition { name, description, parameters: ZodSchema, execute }`
+interface, which is unchanged.
+
+### Phase 2 — me, sequential
+
+- Workspace-wide typecheck: `bun run --cwd packages/core typecheck` and
+ `bun run --cwd packages/frontend typecheck` (which is `svelte-check`).
+- Workspace-wide tests: `bun run --cwd packages/core test`,
+ `bun run --cwd packages/api test`, `bun run --cwd packages/frontend test`.
+- `bun run check` (biome).
+- Fix any integration gaps that Phase 1 agents missed (e.g. type
+ mismatches between agent.ts and provider.ts).
+
+### Phase 3 — Gemini code review
+
+Headless: `gemini --yolo --skip-trust -m gemini-3-pro-preview -p "$(cat
+prompt)"`. Prompt restricts Gemini to read-only inspection and to writing
+ONLY `report.md` at the project root.
+
+### Phase 4 — me, sequential
+
+Apply each fix Gemini flags. Re-run typecheck + tests + biome.
+
+## Risks & mitigations
+
+- **Sequential dependency between phases.** Phase 1 agents must wait for
+ Phase 0 to land before they start, because they read the new
+ `AgentEvent`/`Chunk` types. Mitigated by doing Phase 0 in-conversation.
+- **Phase 1 agents writing diverging adapter code.** Mitigated by this
+ spec locking down exact AgentEvent variants and exact tool-event
+ mappings.
+- **Frontend rendering regressions.** Mitigated by keeping the chunk
+ shape stable — the only change for the frontend is `signature` →
+ `metadata` (an internal detail the UI doesn't render anyway).
+- **Subtle v6 streaming semantics we miss.** Mitigated by Gemini
+ read-only review against opencode reference patterns.
diff --git a/report.md b/report.md
new file mode 100644
index 0000000..7ec0a40
--- /dev/null
+++ b/report.md
@@ -0,0 +1,36 @@
+# Code Review — DeepSeek `reasoning_content` fix
+
+## Verdict
+**Ship-with-followups.** The removal of the v4-era middleware correctly resolves the primary bug: `reasoning_content` is now passed to the AI SDK as `{ type: "reasoning" }` parts, which the v6 SDK successfully serializes to the wire format instead of burying it in a dead `providerMetadata` key. However, the fix strictly relies on the AI SDK's native serialization, which introduces a critical edge case for empty reasoning blocks that the reference OpenCode implementation explicitly handles.
+
+## Bugs found (must-fix)
+*(None blocking the immediate happy-path fix, but see edge cases below)*
+
+## Edge cases / risks
+* **The Empty-Reasoning Edge Case:** DeepSeek (and other models) can occasionally emit an empty reasoning block. In Dispatch, `toModelMessages` will correctly emit `{ type: "reasoning", text: "" }`. However, `@ai-sdk/[email protected]` strips `reasoning_content` entirely if the text is empty:
+ ```javascript
+ // node_modules/@ai-sdk/openai-compatible/dist/index.mjs:245
+ ...reasoning.length > 0 ? { reasoning_content: reasoning } : {},
+ ```
+ If DeepSeek emitted an empty reasoning block in a previous turn, the SDK will drop `reasoning_content` from the subsequent request payload. DeepSeek requires this field to be present (even if empty) during a thinking-mode session and will crash with the exact same "must be passed back" error.
+
+## opencode reference parity gaps
+* **Forced empty string serialization:** The OpenCode reference implementation specifically works around the SDK's empty-string stripping behavior in `packages/opencode/src/provider/transform.ts:233-241`:
+ ```typescript
+ // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
+ // reasoning_content which still needs to be sent back in subsequent requests.
+ ```
+ It enforces this by stripping the `reasoning` parts and injecting the `providerOptions.openaiCompatible[field]` directly. Dispatch's fix misses this workaround because it relies 100% on the AI SDK's native serialization.
+
+## Test coverage gaps
+* **Empty Reasoning Handling:** The new `agent.test.ts` integration test ("openai-compatible reasoning round-trip...") only tests the happy path with non-empty reasoning (`text: "let me reason about this"`). It should include a test asserting that `text: ""` still results in a valid request (which currently would fail against the wire, though testing this fully might require mocking the provider wire payload rather than just `streamText` arguments).
+* **Tool calls + Reasoning:** There is no test verifying that an assistant message with both reasoning and a tool call correctly passes both to `streamText` for the `openai-compatible` provider (though the logic in `toModelMessages` handles it).
+* **Multiple reasoning blocks:** The AI SDK concatenates multiple reasoning parts natively, but an integration test verifying that Dispatch's `toModelMessages` correctly emits all of them would be valuable.
+
+## Style / consistency
+* **Clean removal:** The deletion of the obsolete middleware and the cleanup of `packages/core/src/llm/provider.ts` is idiomatic and cleanly delegates responsibility to the v6 AI SDK.
+* **Comments:** The new block comment in `provider.ts` accurately explains why the old middleware broke DeepSeek and why the SDK handles it natively now.
+
+## Other observations
+* **Redacted Reasoning:** The removed middleware stripped `redacted-reasoning` types. This is no longer necessary because Dispatch's `toModelMessages` only knows how to emit `text`, `reasoning`, and `tool-call` parts; it intrinsically filters out any other internal chunk types. This means no regressions are introduced regarding redacted reasoning.
+* **Empty part filtering for Anthropic:** The `applyAnthropicStructuralNormalisations` function correctly strips empty reasoning/text parts, but this is safely gated behind `usesAnthropicSDK`, so it will not interfere with DeepSeek's `openai-compatible` path. \ No newline at end of file