diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 06:54:48 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 06:54:48 +0900 |
| commit | 8b17d929e70a43749fd962554214bf8ba3e9380f (patch) | |
| tree | bdff1f409a8fe78850044c23b38436d84cbbcca9 | |
| parent | 25b6aac6d4df02e29a2ad4333272bb0998ecd410 (diff) | |
| download | dispatch-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.lock | 47 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 113 | ||||
| -rw-r--r-- | packages/core/package.json | 9 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | 370 | ||||
| -rw-r--r-- | packages/core/src/chunks/append.ts | 66 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 151 | ||||
| -rw-r--r-- | packages/core/src/tools/registry.ts | 48 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 24 | ||||
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 841 | ||||
| -rw-r--r-- | packages/core/tests/chunks/append.test.ts | 97 | ||||
| -rw-r--r-- | packages/core/tests/llm/provider.test.ts | 392 | ||||
| -rw-r--r-- | packages/core/tests/tools/registry.test.ts | 109 | ||||
| -rw-r--r-- | packages/frontend/serve.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 58 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 9 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 56 | ||||
| -rw-r--r-- | plan-v6-upgrade.md | 450 | ||||
| -rw-r--r-- | report.md | 36 |
19 files changed, 2323 insertions, 560 deletions
@@ -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 |
