From 8b17d929e70a43749fd962554214bf8ba3e9380f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 28 May 2026 06:54:48 +0900 Subject: refactor(core): upgrade ai-sdk v4 → v6 + Anthropic/openai-compatible reasoning round-trip + max-thinking budget audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the LLM stack from ai@4.3.19 + @ai-sdk/anthropic@1.2.12 + @ai-sdk/openai-compatible@0.2 to ai@6.0.191 + @ai-sdk/anthropic@3.0.79 + @ai-sdk/openai-compatible@2.0.48. 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/anthropic@1.x 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 (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. --- packages/api/tests/agent-manager.test.ts | 113 +++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) (limited to 'packages/api/tests') 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((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 + | 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" } }, + }); + }); }); -- cgit v1.2.3