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/frontend/src | |
| 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/frontend/src')
| -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 |
3 files changed, 62 insertions, 6 deletions
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: { |
