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 /packages/core/tests/tools | |
| 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.
Diffstat (limited to 'packages/core/tests/tools')
| -rw-r--r-- | packages/core/tests/tools/registry.test.ts | 109 |
1 files changed, 101 insertions, 8 deletions
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"); + }); }); }); |
