summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
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/frontend/src/lib
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/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte58
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts1
-rw-r--r--packages/frontend/src/lib/types.ts9
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: {