diff --git a/package-lock.json b/package-lock.json index 8bea72dbe..59a10ef29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.43.0-next.7", + "version": "2.46.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.43.0-next.7", + "version": "2.46.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -68,6 +68,7 @@ "node": ">=22.0.0" }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.83", "@gsd-build/engine-darwin-arm64": ">=2.10.2", "@gsd-build/engine-darwin-x64": ">=2.10.2", "@gsd-build/engine-linux-arm64-gnu": ">=2.10.2", @@ -77,6 +78,30 @@ "koffi": "^2.9.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.83", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.83.tgz", + "integrity": "sha512-O8g56htGMxrwbjCbqUqRBMNC0O98B7SkPnfQC7vmo3w2DVnUrBj3qat/IBLB8SI4sjVSZHeJrcK7+ozsCzStSw==", + "license": "SEE LICENSE IN README.md", + "optional": true, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.73.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", @@ -9166,7 +9191,7 @@ }, "packages/pi-coding-agent": { "name": "@gsd/pi-coding-agent", - "version": "2.40.0", + "version": "2.46.1", "dependencies": { "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", diff --git a/package.json b/package.json index 6aa0aba46..463246933 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "typescript": "^5.4.0" }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.83", "@gsd-build/engine-darwin-arm64": ">=2.10.2", "@gsd-build/engine-darwin-x64": ">=2.10.2", "@gsd-build/engine-linux-arm64-gnu": ">=2.10.2", diff --git a/src/resources/extensions/claude-code-cli/index.ts b/src/resources/extensions/claude-code-cli/index.ts new file mode 100644 index 000000000..628df3238 --- /dev/null +++ b/src/resources/extensions/claude-code-cli/index.ts @@ -0,0 +1,28 @@ +/** + * Claude Code CLI Provider Extension + * + * Registers a model provider that delegates inference to the user's + * locally-installed Claude Code CLI via the official Agent SDK. + * + * Users with a Claude Code subscription (Pro/Max/Team) get access to + * subsidized inference through GSD's UI — no API key required. + * + * TOS-compliant: uses Anthropic's official `@anthropic-ai/claude-agent-sdk`, + * never touches credentials, never offers a login flow. + */ + +import type { ExtensionAPI } from "@gsd/pi-coding-agent"; +import { CLAUDE_CODE_MODELS } from "./models.js"; +import { isClaudeCodeReady } from "./readiness.js"; +import { streamViaClaudeCode } from "./stream-adapter.js"; + +export default function claudeCodeCli(pi: ExtensionAPI) { + pi.registerProvider("claude-code", { + authMode: "externalCli", + api: "anthropic-messages", + baseUrl: "local://claude-code", + isReady: isClaudeCodeReady, + streamSimple: streamViaClaudeCode, + models: CLAUDE_CODE_MODELS, + }); +} diff --git a/src/resources/extensions/claude-code-cli/models.ts b/src/resources/extensions/claude-code-cli/models.ts new file mode 100644 index 000000000..66edcf67c --- /dev/null +++ b/src/resources/extensions/claude-code-cli/models.ts @@ -0,0 +1,39 @@ +/** + * Model definitions for the Claude Code CLI provider. + * + * Costs are zero because inference is covered by the user's Claude Code + * subscription. The SDK's `result` message still provides token counts + * for display in the TUI. + */ + +const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +export const CLAUDE_CODE_MODELS = [ + { + id: "claude-opus-4-20250514", + name: "Claude Opus 4 (via Claude Code)", + reasoning: true, + input: ["text", "image"] as ("text" | "image")[], + cost: ZERO_COST, + contextWindow: 200_000, + maxTokens: 32_768, + }, + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4 (via Claude Code)", + reasoning: true, + input: ["text", "image"] as ("text" | "image")[], + cost: ZERO_COST, + contextWindow: 200_000, + maxTokens: 16_384, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5 (via Claude Code)", + reasoning: false, + input: ["text", "image"] as ("text" | "image")[], + cost: ZERO_COST, + contextWindow: 200_000, + maxTokens: 8_192, + }, +]; diff --git a/src/resources/extensions/claude-code-cli/package.json b/src/resources/extensions/claude-code-cli/package.json new file mode 100644 index 000000000..b22297d08 --- /dev/null +++ b/src/resources/extensions/claude-code-cli/package.json @@ -0,0 +1,11 @@ +{ + "name": "@gsd/claude-code-cli", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/resources/extensions/claude-code-cli/partial-builder.ts b/src/resources/extensions/claude-code-cli/partial-builder.ts new file mode 100644 index 000000000..6886cccee --- /dev/null +++ b/src/resources/extensions/claude-code-cli/partial-builder.ts @@ -0,0 +1,258 @@ +/** + * Content-block mapping helpers and streaming state tracker. + * + * Translates the Claude Agent SDK's `BetaRawMessageStreamEvent` sequence + * into GSD's `AssistantMessageEvent` deltas for incremental TUI rendering. + */ + +import type { + AssistantMessage, + AssistantMessageEvent, + ServerToolUseContent, + StopReason, + TextContent, + ThinkingContent, + ToolCall, + Usage, + WebSearchResultContent, +} from "@gsd/pi-ai"; +import type { BetaContentBlock, BetaRawMessageStreamEvent, NonNullableUsage } from "./sdk-types.js"; + +// --------------------------------------------------------------------------- +// Content-block mapping helpers +// --------------------------------------------------------------------------- + +/** + * Convert a single BetaContentBlock to the corresponding GSD content type. + */ +export function mapContentBlock( + block: BetaContentBlock, +): TextContent | ThinkingContent | ToolCall | ServerToolUseContent | WebSearchResultContent { + switch (block.type) { + case "text": + return { type: "text", text: block.text } satisfies TextContent; + + case "thinking": + return { + type: "thinking", + thinking: block.thinking, + ...(block.signature ? { thinkingSignature: block.signature } : {}), + } satisfies ThinkingContent; + + case "tool_use": + return { + type: "toolCall", + id: block.id, + name: block.name, + arguments: block.input, + } satisfies ToolCall; + + case "server_tool_use": + return { + type: "serverToolUse", + id: block.id, + name: block.name, + input: block.input, + } satisfies ServerToolUseContent; + + case "web_search_tool_result": + return { + type: "webSearchResult", + toolUseId: block.tool_use_id, + content: block.content, + } satisfies WebSearchResultContent; + + default: { + const unknown = block as Record; + return { type: "text", text: `[unknown content block: ${JSON.stringify(unknown)}]` }; + } + } +} + +export function mapStopReason(reason: string | null): StopReason { + switch (reason) { + case "end_turn": + case "stop_sequence": + return "stop"; + case "max_tokens": + return "length"; + case "tool_use": + return "toolUse"; + default: + return "stop"; + } +} + +/** + * Convert SDK usage + total_cost_usd into GSD's Usage shape. + * + * The SDK does not break cost down per-bucket, so all cost is + * attributed to `cost.total`. + */ +export function mapUsage(sdkUsage: NonNullableUsage, totalCostUsd: number): Usage { + return { + input: sdkUsage.input_tokens, + output: sdkUsage.output_tokens, + cacheRead: sdkUsage.cache_read_input_tokens, + cacheWrite: sdkUsage.cache_creation_input_tokens, + totalTokens: + sdkUsage.input_tokens + + sdkUsage.output_tokens + + sdkUsage.cache_read_input_tokens + + sdkUsage.cache_creation_input_tokens, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: totalCostUsd, + }, + }; +} + +// --------------------------------------------------------------------------- +// Zero-cost usage constant +// --------------------------------------------------------------------------- + +export const ZERO_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +// --------------------------------------------------------------------------- +// Streaming partial-message state tracker +// --------------------------------------------------------------------------- + +/** + * Mutable accumulator that tracks the partial AssistantMessage being built + * from a sequence of stream_event messages. Produces AssistantMessageEvent + * deltas that the TUI can render incrementally. + */ +export class PartialMessageBuilder { + private partial: AssistantMessage; + /** Map from stream-event `index` to our content array index. */ + private indexMap = new Map(); + /** Accumulated JSON input string per tool_use block (keyed by stream index). */ + private toolJsonAccum = new Map(); + + constructor(model: string) { + this.partial = { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "claude-code", + model, + usage: { ...ZERO_USAGE }, + stopReason: "stop", + timestamp: Date.now(), + }; + } + + get message(): AssistantMessage { + return this.partial; + } + + /** + * Feed a BetaRawMessageStreamEvent and return the corresponding + * AssistantMessageEvent (or null if the event is not mapped). + */ + handleEvent(event: BetaRawMessageStreamEvent): AssistantMessageEvent | null { + const streamIndex = event.index ?? 0; + + switch (event.type) { + // ---- Block start ---- + case "content_block_start": { + const block = event.content_block; + if (!block) return null; + + const contentIndex = this.partial.content.length; + this.indexMap.set(streamIndex, contentIndex); + + if (block.type === "text") { + this.partial.content.push({ type: "text", text: "" }); + return { type: "text_start", contentIndex, partial: this.partial }; + } + if (block.type === "thinking") { + this.partial.content.push({ type: "thinking", thinking: "" }); + return { type: "thinking_start", contentIndex, partial: this.partial }; + } + if (block.type === "tool_use") { + this.toolJsonAccum.set(streamIndex, ""); + this.partial.content.push({ + type: "toolCall", + id: block.id, + name: block.name, + arguments: {}, + }); + return { type: "toolcall_start", contentIndex, partial: this.partial }; + } + if (block.type === "server_tool_use") { + this.partial.content.push({ + type: "serverToolUse", + id: block.id, + name: block.name, + input: block.input, + }); + return { type: "server_tool_use", contentIndex, partial: this.partial }; + } + return null; + } + + // ---- Block delta ---- + case "content_block_delta": { + const contentIndex = this.indexMap.get(streamIndex); + if (contentIndex === undefined) return null; + const delta = event.delta; + if (!delta) return null; + + if (delta.type === "text_delta" && typeof delta.text === "string") { + const existing = this.partial.content[contentIndex] as TextContent; + existing.text += delta.text; + return { type: "text_delta", contentIndex, delta: delta.text, partial: this.partial }; + } + if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { + const existing = this.partial.content[contentIndex] as ThinkingContent; + existing.thinking += delta.thinking; + return { type: "thinking_delta", contentIndex, delta: delta.thinking, partial: this.partial }; + } + if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + const accum = (this.toolJsonAccum.get(streamIndex) ?? "") + delta.partial_json; + this.toolJsonAccum.set(streamIndex, accum); + return { type: "toolcall_delta", contentIndex, delta: delta.partial_json, partial: this.partial }; + } + return null; + } + + // ---- Block stop ---- + case "content_block_stop": { + const contentIndex = this.indexMap.get(streamIndex); + if (contentIndex === undefined) return null; + const block = this.partial.content[contentIndex]; + + if (block.type === "text") { + return { type: "text_end", contentIndex, content: block.text, partial: this.partial }; + } + if (block.type === "thinking") { + return { type: "thinking_end", contentIndex, content: block.thinking, partial: this.partial }; + } + if (block.type === "toolCall") { + const jsonStr = this.toolJsonAccum.get(streamIndex) ?? "{}"; + try { + block.arguments = JSON.parse(jsonStr); + } catch { + block.arguments = { _raw: jsonStr }; + } + return { type: "toolcall_end", contentIndex, toolCall: block, partial: this.partial }; + } + return null; + } + + default: + return null; + } + } +} diff --git a/src/resources/extensions/claude-code-cli/readiness.ts b/src/resources/extensions/claude-code-cli/readiness.ts new file mode 100644 index 000000000..94a59a6b5 --- /dev/null +++ b/src/resources/extensions/claude-code-cli/readiness.ts @@ -0,0 +1,30 @@ +/** + * Readiness check for the Claude Code CLI provider. + * + * Verifies the `claude` binary is installed and responsive. + * Result is cached for 30 seconds to avoid shelling out on every + * model-availability check. + */ + +import { execSync } from "node:child_process"; + +let cachedReady: boolean | null = null; +let lastCheckMs = 0; +const CHECK_INTERVAL_MS = 30_000; + +export function isClaudeCodeReady(): boolean { + const now = Date.now(); + if (cachedReady !== null && now - lastCheckMs < CHECK_INTERVAL_MS) { + return cachedReady; + } + + try { + execSync("claude --version", { timeout: 5_000, stdio: "pipe" }); + cachedReady = true; + } catch { + cachedReady = false; + } + + lastCheckMs = now; + return cachedReady; +} diff --git a/src/resources/extensions/claude-code-cli/sdk-types.ts b/src/resources/extensions/claude-code-cli/sdk-types.ts new file mode 100644 index 000000000..040175cdc --- /dev/null +++ b/src/resources/extensions/claude-code-cli/sdk-types.ts @@ -0,0 +1,149 @@ +/** + * Lightweight type mirrors for the Claude Agent SDK. + * + * These stubs allow the extension to compile without a hard dependency on + * `@anthropic-ai/claude-agent-sdk`. The real SDK is imported dynamically + * at runtime in stream-adapter.ts. + */ + +/** UUID branded string from the SDK. */ +export type UUID = string; + +/** BetaMessage from the Anthropic SDK, as wrapped by SDKAssistantMessage. */ +export interface BetaMessage { + id: string; + type: "message"; + role: "assistant"; + content: BetaContentBlock[]; + model: string; + stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null; + usage: { input_tokens: number; output_tokens: number }; +} + +export type BetaContentBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "tool_use"; id: string; name: string; input: Record } + | { type: "server_tool_use"; id: string; name: string; input: unknown } + | { type: "web_search_tool_result"; tool_use_id: string; content: unknown }; + +/** Streaming event emitted when includePartialMessages is true. */ +export interface BetaRawMessageStreamEvent { + type: string; + index?: number; + content_block?: BetaContentBlock; + delta?: Record; +} + +export interface SDKAssistantMessage { + type: "assistant"; + uuid: UUID; + session_id: string; + message: BetaMessage; + parent_tool_use_id: string | null; + error?: { type: string; message: string }; +} + +export interface SDKUserMessage { + type: "user"; + uuid?: UUID; + session_id: string; + message: unknown; + parent_tool_use_id: string | null; + isSynthetic?: boolean; + tool_use_result?: unknown; +} + +export interface SDKSystemMessage { + type: "system"; + subtype: "init"; + [key: string]: unknown; +} + +export interface SDKStatusMessage { + type: "system"; + subtype: "status"; + status: "compacting" | null; + uuid: UUID; + session_id: string; +} + +export interface SDKPartialAssistantMessage { + type: "stream_event"; + event: BetaRawMessageStreamEvent; + parent_tool_use_id: string | null; + uuid: UUID; + session_id: string; +} + +export interface SDKToolProgressMessage { + type: "tool_progress"; + tool_use_id: string; + tool_name: string; + parent_tool_use_id: string | null; + elapsed_time_seconds: number; + task_id?: string; + uuid: UUID; + session_id: string; +} + +export interface NonNullableUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; +} + +export type SDKResultMessage = + | { + type: "result"; + subtype: "success"; + uuid: UUID; + session_id: string; + duration_ms: number; + duration_api_ms: number; + is_error: boolean; + num_turns: number; + result: string; + stop_reason: string | null; + total_cost_usd: number; + usage: NonNullableUsage; + } + | { + type: "result"; + subtype: + | "error_max_turns" + | "error_during_execution" + | "error_max_budget_usd" + | "error_max_structured_output_retries"; + uuid: UUID; + session_id: string; + duration_ms: number; + duration_api_ms: number; + is_error: boolean; + num_turns: number; + stop_reason: string | null; + total_cost_usd: number; + usage: NonNullableUsage; + errors: string[]; + }; + +/** Catch-all for SDK message types we don't map. */ +export interface SDKOtherMessage { + type: string; + [key: string]: unknown; +} + +/** + * Union of all SDK message types this extension handles. + * Mirrors the real `SDKMessage` from `@anthropic-ai/claude-agent-sdk`. + */ +export type SDKMessage = + | SDKAssistantMessage + | SDKUserMessage + | SDKResultMessage + | SDKSystemMessage + | SDKStatusMessage + | SDKPartialAssistantMessage + | SDKToolProgressMessage + | SDKOtherMessage; diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts new file mode 100644 index 000000000..0327c00a6 --- /dev/null +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -0,0 +1,331 @@ +/** + * Stream adapter: bridges the Claude Agent SDK into GSD's streamSimple contract. + * + * The SDK runs the full agentic loop (multi-turn, tool execution, compaction) + * in one call. This adapter translates the SDK's streaming output into + * AssistantMessageEvents for TUI rendering, then strips tool-call blocks from + * the final AssistantMessage so GSD's agent loop doesn't try to dispatch them. + */ + +import type { + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + Model, + SimpleStreamOptions, +} from "@gsd/pi-ai"; +import { EventStream } from "@gsd/pi-ai"; +import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js"; +import type { + SDKAssistantMessage, + SDKMessage, + SDKPartialAssistantMessage, + SDKResultMessage, + SDKSystemMessage, + SDKStatusMessage, + SDKUserMessage, +} from "./sdk-types.js"; + +// --------------------------------------------------------------------------- +// Stream factory +// --------------------------------------------------------------------------- + +/** + * Construct an AssistantMessageEventStream using EventStream directly. + * (The class itself is only re-exported as a type from the @gsd/pi-ai barrel.) + */ +function createAssistantStream(): AssistantMessageEventStream { + return new EventStream( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type for final result"); + }, + ) as AssistantMessageEventStream; +} + +// --------------------------------------------------------------------------- +// Prompt extraction +// --------------------------------------------------------------------------- + +/** + * Extract the last user prompt text from GSD's context messages. + * The SDK manages its own conversation history — we only send + * the latest user message as the prompt. + */ +function extractLastUserPrompt(context: Context): string { + for (let i = context.messages.length - 1; i >= 0; i--) { + const msg = context.messages[i]; + if (msg.role === "user") { + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + const textParts = msg.content + .filter((part: any) => part.type === "text") + .map((part: any) => part.text); + if (textParts.length > 0) return textParts.join("\n"); + } + } + } + return ""; +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +function makeErrorMessage(model: string, errorMsg: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: `Claude Code error: ${errorMsg}` }], + api: "anthropic-messages", + provider: "claude-code", + model, + usage: { ...ZERO_USAGE }, + stopReason: "error", + errorMessage: errorMsg, + timestamp: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// streamSimple implementation +// --------------------------------------------------------------------------- + +/** + * GSD streamSimple function that delegates to the Claude Agent SDK. + * + * Emits AssistantMessageEvent deltas for real-time TUI rendering + * (thinking, text, tool calls). The final AssistantMessage has tool-call + * blocks stripped so the agent loop ends the turn without local dispatch. + */ +export function streamViaClaudeCode( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + const stream = createAssistantStream(); + + void pumpSdkMessages(model, context, options, stream); + + return stream; +} + +async function pumpSdkMessages( + model: Model, + context: Context, + options: SimpleStreamOptions | undefined, + stream: AssistantMessageEventStream, +): Promise { + const modelId = model.id; + let builder: PartialMessageBuilder | null = null; + /** Track the last text content seen across all assistant turns for the final message. */ + let lastTextContent = ""; + let lastThinkingContent = ""; + + try { + // Dynamic import — the SDK is an optional dependency. + const sdkModule = "@anthropic-ai/claude-agent-sdk"; + const sdk = (await import(/* webpackIgnore: true */ sdkModule)) as { + query: (args: { + prompt: string | AsyncIterable; + options?: Record; + }) => AsyncIterable; + }; + + // Bridge GSD's AbortSignal to SDK's AbortController + const controller = new AbortController(); + if (options?.signal) { + options.signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + + const prompt = extractLastUserPrompt(context); + + const queryResult = sdk.query({ + prompt, + options: { + model: modelId, + includePartialMessages: true, + persistSession: false, + abortController: controller, + cwd: process.cwd(), + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code" }, + env: { CLAUDE_AGENT_SDK_CLIENT_APP: "gsd" }, + betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [], + }, + }); + + // Emit start with an empty partial + const initialPartial: AssistantMessage = { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "claude-code", + model: modelId, + usage: { ...ZERO_USAGE }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "start", partial: initialPartial }); + + for await (const msg of queryResult as AsyncIterable) { + if (options?.signal?.aborted) break; + + switch (msg.type) { + // -- Init -- + case "system": { + // Nothing to emit — the stream is already started. + break; + } + + // -- Streaming partial messages -- + case "stream_event": { + const partial = msg as SDKPartialAssistantMessage; + if (partial.parent_tool_use_id !== null) break; // skip subagent + + const event = partial.event; + + // New assistant turn starts with message_start + if (event.type === "message_start") { + builder = new PartialMessageBuilder( + (event as any).message?.model ?? modelId, + ); + break; + } + + if (!builder) break; + + const assistantEvent = builder.handleEvent(event); + if (assistantEvent) { + stream.push(assistantEvent); + } + break; + } + + // -- Complete assistant message (non-streaming fallback) -- + case "assistant": { + const sdkAssistant = msg as SDKAssistantMessage; + if (sdkAssistant.parent_tool_use_id !== null) break; + + // Capture text content from complete messages + for (const block of sdkAssistant.message.content) { + if (block.type === "text") { + lastTextContent = block.text; + } else if (block.type === "thinking") { + lastThinkingContent = block.thinking; + } + } + break; + } + + // -- User message (synthetic tool result — signals turn boundary) -- + case "user": { + const userMsg = msg as SDKUserMessage; + if (userMsg.parent_tool_use_id !== null) break; + + // Capture accumulated text from the builder before resetting + if (builder) { + for (const block of builder.message.content) { + if (block.type === "text" && block.text) { + lastTextContent = block.text; + } else if (block.type === "thinking" && block.thinking) { + lastThinkingContent = block.thinking; + } + } + } + builder = null; + break; + } + + // -- Result (terminal) -- + case "result": { + const result = msg as SDKResultMessage; + + // Build final message with text/thinking only (strip tool calls) + const finalContent: AssistantMessage["content"] = []; + + // Use builder's accumulated content if available, falling back to captured text + if (builder) { + for (const block of builder.message.content) { + if (block.type === "text" && block.text) { + lastTextContent = block.text; + } else if (block.type === "thinking" && block.thinking) { + lastThinkingContent = block.thinking; + } + } + } + + if (lastThinkingContent) { + finalContent.push({ type: "thinking", thinking: lastThinkingContent }); + } + if (lastTextContent) { + finalContent.push({ type: "text", text: lastTextContent }); + } + + // Fallback: use the SDK's result text if we have no content + if (finalContent.length === 0 && result.subtype === "success" && result.result) { + finalContent.push({ type: "text", text: result.result }); + } + + const finalMessage: AssistantMessage = { + role: "assistant", + content: finalContent, + api: "anthropic-messages", + provider: "claude-code", + model: modelId, + usage: mapUsage(result.usage, result.total_cost_usd), + stopReason: result.is_error ? "error" : "stop", + timestamp: Date.now(), + }; + + if (result.is_error) { + const errText = + "errors" in result + ? (result as any).errors?.join("; ") + : result.subtype; + finalMessage.errorMessage = errText; + stream.push({ type: "error", reason: "error", error: finalMessage }); + } else { + stream.push({ type: "done", reason: "stop", message: finalMessage }); + } + return; + } + + default: + break; + } + } + + // Generator exhausted without a result message (unexpected) + const fallbackContent: AssistantMessage["content"] = []; + if (lastTextContent) { + fallbackContent.push({ type: "text", text: lastTextContent }); + } + if (fallbackContent.length === 0) { + fallbackContent.push({ type: "text", text: "(Claude Code session ended without a response)" }); + } + + const fallback: AssistantMessage = { + role: "assistant", + content: fallbackContent, + api: "anthropic-messages", + provider: "claude-code", + model: modelId, + usage: { ...ZERO_USAGE }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "done", reason: "stop", message: fallback }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + stream.push({ + type: "error", + reason: "error", + error: makeErrorMessage(modelId, errorMsg), + }); + } +}