summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 06:54:48 +0900
committerAdam Malczewski <[email protected]>2026-05-28 06:54:48 +0900
commit8b17d929e70a43749fd962554214bf8ba3e9380f (patch)
treebdff1f409a8fe78850044c23b38436d84cbbcca9 /packages/core/tests/tools
parent25b6aac6d4df02e29a2ad4333272bb0998ecd410 (diff)
downloaddispatch-8b17d929e70a43749fd962554214bf8ba3e9380f.tar.gz
dispatch-8b17d929e70a43749fd962554214bf8ba3e9380f.zip
refactor(core): upgrade ai-sdk v4 → v6 + Anthropic/openai-compatible reasoning round-trip + max-thinking budget audit
Migrates the LLM stack from [email protected] + @ai-sdk/[email protected] + @ai-sdk/[email protected] to [email protected] + @ai-sdk/[email protected] + @ai-sdk/[email protected]. Full design in plan-v6-upgrade.md; two rounds of Gemini code review captured in report.md. Motivation: the recurring 'reasoning-signature without reasoning' error on Claude Opus 4.7 was a v4 SDK artefact — @ai-sdk/[email protected] emitted Anthropic signature_delta as a separate stream chunk that orphaned when the model produced a signed-but-empty thinking block, and our chunk store had no signature field so the round-trip back to Anthropic was rejected on the next turn. In v6, signatures arrive inside providerMetadata on the reasoning-end event, and the orphan-signature class of bug is gone at the SDK level. Core changes: • ThinkingChunk gains optional metadata?: Record<string, unknown> (the v6 providerMetadata blob). A non-undefined metadata 'seals' the chunk: subsequent reasoning-delta opens a new chunk rather than extending the sealed one. • AgentEvent gains { type: 'reasoning-end'; metadata? } (replaces the v4 reasoning-signature variant). • toModelMessages (replaces toCoreMessages): - returns ModelMessage[] (was CoreMessage[]) - thinking → { type: 'reasoning', text, providerOptions: metadata } - tool-batch entries → { type: 'tool-call', input } (was 'args') - tool results → { output: { type: 'text', value } } ToolResultOutput • Claude OAuth uses createAnthropic({ authToken }) natively — no more custom-fetch x-api-key → Bearer swap. • rewriteBodyForOpus47 deleted — Opus 4.7 adaptive thinking is native via providerOptions.anthropic.thinking = { type: 'adaptive' }. • V1 middleware → V3 (specificationVersion: 'v3'). • v4-era normalizeMessages openai-compatible middleware deleted; the v6 openai-compatible provider extracts reasoning_content natively from { type: 'reasoning' } content parts. • applyAnthropicStructuralNormalisations (mirrors opencode provider/transform.ts:53-148): drops empty text/reasoning parts, scrubs non-[a-zA-Z0-9_-] toolCallIds, splits [tool-call, non-tool] assistant turns (Anthropic rejects tool_use followed by text). • applyOpenAICompatibleReasoningNormalisation (mirrors opencode transform.ts:217-249): lifts reasoning text into providerOptions.openaiCompatible.reasoning_content (always, even empty). Solves DeepSeek 'The reasoning_content in the thinking mode must be passed back' — the v6 SDK skips emitting reasoning_content when text is empty (dist/index.mjs:245), but DeepSeek requires the field present once thinking was used. • Tools: tool({ inputSchema: jsonSchema(zodToJsonSchema(...)) }) (was parameters: ZodSchema). AI SDK tools have no execute callback — the agent runs tools manually for permission prompts and shell-output streaming. New dep: zod-to-json-schema@^3.25.2. • fullStream event loop rewritten for v6 event shape: text-delta (text not textDelta), reasoning-start/delta/end, tool-input-*, tool-call (input not args), tool-result, tool-error (new), abort (new), start-step/finish-step, finish. Max-thinking audit (matches opencode transform.ts:642-671 budgets): • Claude enabled-thinking max budget 16000 → 31999 (Anthropic ceiling) • Claude enabled-thinking high budget 10000 → 16000 • maxOutputTokens 'budget + 8000' → fixed 32000 (matches opencode's OUTPUT_TOKEN_MAX; model self-allocates thinking vs response within) • Opus 4.7 adaptive thinking gains display: 'summarized' and sibling effort field (without these, thinking content is hidden by Anthropic and the model barely thinks). Frontend mirrors: • types.ts — ThinkingChunk.metadata?, AgentEvent reasoning-end • tabs.svelte.ts — routes reasoning-end through applyChunkEvent • ChatMessage.svelte — hides empty thinking chunks; hides the entire assistant bubble when no chunk has renderable content Gemini-review-driven fixes: • tool-error and abort stream events now surface as error chunks (were silently ignored) • toolCallId scrubbing pass (opencode transform.ts:96-122 parity) • Empty-reasoning-cull explicit test coverage for both Anthropic structural normalisation and DeepSeek path Test counts (223 tests across 3 packages, all green): • tests/chunks/append.test.ts: 44 (was 38) — reasoning-end sealing, orphan walk-back, multi-block interleaving • tests/agent/agent.test.ts: 24 (was 5) — exhaustive v6 event mappings, structural normalisations, signature/reasoning_content round-trip, tool-error/abort branches, DeepSeek scenario, empty reasoning edge case • tests/llm/provider.test.ts: 9 (was 22) — dropped 13 obsolete v4 middleware tests; new minimal tests confirm no middleware wrapping on default openai-compat path and that createAnthropic gets authToken vs apiKey correctly for OAuth vs api-key flows • tests/tools/registry.test.ts: 10 (was 4) — v6 tool() contract (inputSchema, no execute, JSON Schema for nested zod) • packages/api/tests/agent-manager.test.ts: 12 (was 7) — mock Agent emits v6 reasoning events; reasoning-end broadcast + ordering • packages/frontend/tests/chat-store.test.ts: 35 (was 32) — reasoning-end flow through Svelte $state store typecheck clean (tsc --noEmit on core + api, svelte-check on frontend), biome clean across 124 files.
Diffstat (limited to 'packages/core/tests/tools')
-rw-r--r--packages/core/tests/tools/registry.test.ts109
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");
+ });
});
});