/** * 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, ToolCall, } from "@gsd/pi-ai"; import type { ExtensionUIContext } from "@gsd/pi-coding-agent"; import { EventStream } from "@gsd/pi-ai"; import { execSync } from "node:child_process"; import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js"; import { buildWorkflowMcpServers } from "../gsd/workflow-mcp.js"; import { showInterviewRound, type Question, type RoundResult } from "../shared/tui.js"; import type { SDKAssistantMessage, SDKMessage, SDKPartialAssistantMessage, SDKResultMessage, SDKUserMessage, } from "./sdk-types.js"; export interface ExternalToolResultContentBlock { type: string; text?: string; data?: string; mimeType?: string; } export interface ExternalToolResultPayload { content: ExternalToolResultContentBlock[]; details?: Record; isError: boolean; } type ToolCallWithExternalResult = ToolCall & { externalResult?: ExternalToolResultPayload; }; interface ClaudeCodeStreamOptions extends SimpleStreamOptions { extensionUIContext?: ExtensionUIContext; } interface SdkElicitationRequestOption { const?: string; title?: string; } interface SdkElicitationFieldSchema { type?: string; title?: string; description?: string; format?: string; writeOnly?: boolean; oneOf?: SdkElicitationRequestOption[]; items?: { anyOf?: SdkElicitationRequestOption[]; }; } interface SdkElicitationRequest { serverName: string; message: string; mode?: "form" | "url"; requestedSchema?: { type?: string; properties?: Record; required?: string[]; }; } interface SdkElicitationResult { action: "accept" | "decline" | "cancel"; content?: Record; } interface ParsedElicitationQuestion extends Question { noteFieldId?: string; } interface ParsedTextInputField { id: string; title: string; description: string; required: boolean; secure: boolean; } const OTHER_OPTION_LABEL = "None of the above"; const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i; // --------------------------------------------------------------------------- // 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; } export function getResultErrorMessage(result: SDKResultMessage): string { if ("errors" in result && Array.isArray(result.errors) && result.errors.length > 0) { return result.errors.join("; "); } if ("result" in result && typeof result.result === "string" && result.result.trim().length > 0) { return result.result.trim(); } return result.subtype === "success" ? "claude_code_request_failed" : result.subtype; } // --------------------------------------------------------------------------- // Claude binary resolution // --------------------------------------------------------------------------- let cachedClaudePath: string | null = null; export function getClaudeLookupCommand(platform: NodeJS.Platform = process.platform): string { return platform === "win32" ? "where claude" : "which claude"; } export function parseClaudeLookupOutput(output: Buffer | string): string { return output .toString() .trim() .split(/\r?\n/)[0] ?? ""; } /** * Resolve the path to the system-installed `claude` binary. * The SDK defaults to a bundled cli.js which doesn't exist when * installed as a library — we need to point it at the real CLI. */ function getClaudePath(): string { if (cachedClaudePath) return cachedClaudePath; try { cachedClaudePath = parseClaudeLookupOutput(execSync(getClaudeLookupCommand(), { timeout: 5_000, stdio: "pipe" })); } catch { cachedClaudePath = "claude"; // fall back to PATH resolution } return cachedClaudePath; } // --------------------------------------------------------------------------- // Prompt construction // --------------------------------------------------------------------------- /** * Extract text content from a single message regardless of content shape. */ function extractMessageText(msg: { role: string; content: unknown }): string { 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 ?? part.thinking ?? ""); if (textParts.length > 0) return textParts.join("\n"); } return ""; } /** * Build a full conversational prompt from GSD's context messages. * * Previous behaviour sent only the last user message, making every SDK * call effectively stateless. This version serialises the complete * conversation history (system prompt + all user/assistant turns) so * Claude Code has full context for multi-turn continuity. * * History is wrapped in XML-tag structure rather than `[User]`/`[Assistant]` * bracket headers. Bracket headers read to the model as an in-context * demonstration of how turns are delimited, causing it to fabricate fake * user turns in its own output. XML tags read as document structure and * don't get mirrored in free text. */ export function buildPromptFromContext(context: Context): string { const hasContent = Boolean(context.systemPrompt) || context.messages.some((m) => extractMessageText(m)); if (!hasContent) return ""; const parts: string[] = [ "Respond only to the final user message below. " + "Do not emit , , or tags in your response.", ]; if (context.systemPrompt) { parts.push(`\n${context.systemPrompt}\n`); } const turns: string[] = []; for (const msg of context.messages) { const text = extractMessageText(msg); if (!text) continue; const tag = msg.role === "user" ? "user_message" : msg.role === "assistant" ? "assistant_message" : "system_message"; turns.push(`<${tag}>\n${text}\n`); } if (turns.length > 0) { parts.push(`\n${turns.join("\n")}\n`); } return parts.join("\n\n"); } // --------------------------------------------------------------------------- // 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(), }; } /** * Generator exhaustion without a terminal result means the SDK stream was * interrupted mid-turn. Surface it as an error so downstream recovery logic * can classify and retry it instead of treating it as a clean completion. */ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: string): AssistantMessage { const errorMsg = "stream_exhausted_without_result"; const message = makeErrorMessage(model, errorMsg); if (lastTextContent) { message.content = [{ type: "text", text: lastTextContent }]; } return message; } function readElicitationChoices(options: SdkElicitationRequestOption[] | undefined): string[] { if (!Array.isArray(options)) return []; return options .map((option) => (typeof option?.const === "string" ? option.const : typeof option?.title === "string" ? option.title : "")) .filter((option): option is string => option.length > 0); } export function parseAskUserQuestionsElicitation( request: Pick, ): ParsedElicitationQuestion[] | null { if (request.mode && request.mode !== "form") return null; const properties = request.requestedSchema?.properties; if (!properties || typeof properties !== "object") return null; const questions: ParsedElicitationQuestion[] = []; for (const [fieldId, rawField] of Object.entries(properties)) { if (fieldId.endsWith("__note")) continue; if (!rawField || typeof rawField !== "object") return null; const header = typeof rawField.title === "string" && rawField.title.length > 0 ? rawField.title : fieldId; const question = typeof rawField.description === "string" ? rawField.description : ""; if (rawField.type === "array") { const options = readElicitationChoices(rawField.items?.anyOf).map((label) => ({ label, description: "" })); if (options.length === 0) return null; questions.push({ id: fieldId, header, question, options, allowMultiple: true, }); continue; } if (rawField.type === "string") { const noteFieldId = Object.prototype.hasOwnProperty.call(properties, `${fieldId}__note`) ? `${fieldId}__note` : undefined; const options = readElicitationChoices(rawField.oneOf) .filter((label) => label !== OTHER_OPTION_LABEL) .map((label) => ({ label, description: "" })); if (options.length === 0) return null; questions.push({ id: fieldId, header, question, options, noteFieldId, }); continue; } return null; } return questions.length > 0 ? questions : null; } function isSecureElicitationField( requestMessage: string, fieldId: string, field: SdkElicitationFieldSchema, ): boolean { if (field.format === "password") return true; if (field.writeOnly === true) return true; const rawField = field as Record; if (rawField.sensitive === true || rawField["x-sensitive"] === true) return true; const haystack = [ requestMessage, fieldId.replace(/[_-]+/g, " "), typeof field.title === "string" ? field.title : "", typeof field.description === "string" ? field.description : "", ] .join(" ") .toLowerCase(); return SENSITIVE_FIELD_PATTERN.test(haystack); } export function parseTextInputElicitation( request: Pick, ): ParsedTextInputField[] | null { if (request.mode && request.mode !== "form") return null; const schema = request.requestedSchema as | ({ properties?: Record; keys?: Record } & Record) | undefined; const fieldsSource = schema?.properties && typeof schema.properties === "object" ? schema.properties : schema?.keys && typeof schema.keys === "object" ? schema.keys : undefined; if (!fieldsSource) return null; const requiredSet = new Set( Array.isArray(request.requestedSchema?.required) ? request.requestedSchema.required.filter((value): value is string => typeof value === "string") : [], ); const fields: ParsedTextInputField[] = []; for (const [fieldId, field] of Object.entries(fieldsSource)) { if (!field || typeof field !== "object") continue; if (field.type !== "string") continue; if (Array.isArray(field.oneOf) && field.oneOf.length > 0) continue; fields.push({ id: fieldId, title: typeof field.title === "string" && field.title.length > 0 ? field.title : fieldId, description: typeof field.description === "string" ? field.description : "", required: requiredSet.has(fieldId), secure: isSecureElicitationField(request.message, fieldId, field), }); } return fields.length > 0 ? fields : null; } export function roundResultToElicitationContent( questions: ParsedElicitationQuestion[], result: RoundResult, ): Record { const content: Record = {}; for (const question of questions) { const answer = result.answers[question.id]; if (!answer) continue; if (question.allowMultiple) { const selected = Array.isArray(answer.selected) ? answer.selected : [answer.selected]; content[question.id] = selected; continue; } const selected = Array.isArray(answer.selected) ? answer.selected[0] ?? "" : answer.selected; content[question.id] = selected; if (question.noteFieldId && selected === OTHER_OPTION_LABEL && answer.notes.trim().length > 0) { content[question.noteFieldId] = answer.notes.trim(); } } return content; } function buildElicitationPromptTitle(request: SdkElicitationRequest, question: ParsedElicitationQuestion): string { const parts = [ request.serverName ? `[${request.serverName}]` : "", question.header, question.question, ].filter((part) => part && part.trim().length > 0); return parts.join("\n\n"); } async function promptElicitationWithDialogs( request: SdkElicitationRequest, questions: ParsedElicitationQuestion[], ui: ExtensionUIContext, signal: AbortSignal, ): Promise { const content: Record = {}; for (const question of questions) { const title = buildElicitationPromptTitle(request, question); if (question.allowMultiple) { const selected = await ui.select(title, question.options.map((option) => option.label), { allowMultiple: true, signal, }); if (Array.isArray(selected)) { if (selected.length === 0) return { action: "cancel" }; content[question.id] = selected; continue; } if (typeof selected === "string" && selected.length > 0) { content[question.id] = [selected]; continue; } return { action: "cancel" }; } const selected = await ui.select(title, [...question.options.map((option) => option.label), OTHER_OPTION_LABEL], { signal }); if (typeof selected !== "string" || selected.length === 0) { return { action: "cancel" }; } content[question.id] = selected; if (question.noteFieldId && selected === OTHER_OPTION_LABEL) { const note = await ui.input(`${question.header} note`, "Explain your answer", { signal }); if (note === undefined) return { action: "cancel" }; if (note.trim().length > 0) { content[question.noteFieldId] = note.trim(); } } } return { action: "accept", content }; } function buildTextInputPromptTitle(request: SdkElicitationRequest, field: ParsedTextInputField): string { const parts = [ request.serverName ? `[${request.serverName}]` : "", field.title, field.description, ].filter((part) => typeof part === "string" && part.trim().length > 0); return parts.join("\n\n"); } function buildTextInputPlaceholder(field: ParsedTextInputField): string | undefined { const desc = field.description.trim(); if (!desc) return field.required ? "Required" : "Leave empty to skip"; const formatLine = desc .split(/\r?\n/) .map((line) => line.trim()) .find((line) => /^format:/i.test(line)); if (!formatLine) return field.required ? "Required" : "Leave empty to skip"; const hint = formatLine.replace(/^format:\s*/i, "").trim(); return hint.length > 0 ? hint : field.required ? "Required" : "Leave empty to skip"; } async function promptTextInputElicitation( request: SdkElicitationRequest, fields: ParsedTextInputField[], ui: ExtensionUIContext, signal: AbortSignal, ): Promise { const content: Record = {}; for (const field of fields) { const value = await ui.input( buildTextInputPromptTitle(request, field), buildTextInputPlaceholder(field), { signal, ...(field.secure ? { secure: true } : {}) }, ); if (value === undefined) { return { action: "cancel" }; } content[field.id] = value; } return { action: "accept", content }; } export function createClaudeCodeElicitationHandler( ui: ExtensionUIContext | undefined, ): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise) | undefined { if (!ui) return undefined; return async (request, { signal }) => { if (request.mode === "url") { return { action: "decline" }; } const questions = parseAskUserQuestionsElicitation(request); if (questions) { const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined); if (interviewResult && Object.keys(interviewResult.answers).length > 0) { return { action: "accept", content: roundResultToElicitationContent(questions, interviewResult), }; } return promptElicitationWithDialogs(request, questions, ui, signal); } const textFields = parseTextInputElicitation(request); if (textFields) { return promptTextInputElicitation(request, textFields, ui, signal); } return { action: "decline" }; }; } /** * Aborted by the caller's AbortSignal — distinct from exhaustion. GSD's * agent loop keys off `stopReason === "aborted"` to treat this as a clean * user cancel instead of a retry-eligible provider failure. */ export function makeAbortedMessage(model: string, lastTextContent: string): AssistantMessage { const message: AssistantMessage = { role: "assistant", content: lastTextContent ? [{ type: "text", text: lastTextContent }] : [{ type: "text", text: "Claude Code stream aborted by caller" }], api: "anthropic-messages", provider: "claude-code", model, usage: { ...ZERO_USAGE }, stopReason: "aborted", timestamp: Date.now(), }; return message; } // --------------------------------------------------------------------------- // SDK options builder // --------------------------------------------------------------------------- /** * Resolve the Claude Code permission mode for the current run. * * - Auto-mode / headless runs bypass permissions so tool calls don't block * on prompts the user isn't watching. * - Interactive runs default to `acceptEdits` so file/bash writes still * land quickly but the SDK retains a permission gate. * - `GSD_CLAUDE_CODE_PERMISSION_MODE` forces a specific mode when set. * * Cross-extension coupling is kept minimal by dynamically importing * `isAutoActive` and falling back to the bypass default if the import * fails (e.g. in unit tests that load stream-adapter in isolation). */ export async function resolveClaudePermissionMode( env: NodeJS.ProcessEnv = process.env, ): Promise<"bypassPermissions" | "acceptEdits" | "default" | "plan"> { const override = env.GSD_CLAUDE_CODE_PERMISSION_MODE?.trim(); if (override === "bypassPermissions" || override === "acceptEdits" || override === "default" || override === "plan") { return override; } try { const autoMod = (await import("../gsd/auto.js")) as { isAutoActive?: () => boolean }; if (typeof autoMod.isAutoActive === "function" && autoMod.isAutoActive()) { return "bypassPermissions"; } return "acceptEdits"; } catch { // auto.ts unavailable (tests, non-GSD contexts) — stay permissive. return "bypassPermissions"; } } /** * Build the options object passed to the Claude Agent SDK's `query()` call. * * Extracted for testability — callers can verify session persistence, * beta flags, and other configuration without mocking the full SDK. * * `permissionMode` / `allowDangerouslySkipPermissions` are resolved through * {@link resolveClaudePermissionMode} so interactive runs don't silently * bypass the SDK's permission gate. Callers that want the old always-bypass * behaviour pass `permissionMode: "bypassPermissions"` explicitly. */ export function buildSdkOptions( modelId: string, prompt: string, overrides?: { permissionMode?: "bypassPermissions" | "acceptEdits" | "default" | "plan" }, extraOptions: Record = {}, ): Record { const mcpServers = buildWorkflowMcpServers(); const permissionMode = overrides?.permissionMode ?? "bypassPermissions"; const disallowedTools = ["AskUserQuestion"]; // Pre-authorize every registered workflow MCP server's tools. Without this, // `acceptEdits` mode (the interactive default) auto-approves built-in // Edit/Write/Bash but still gates MCP calls like `mcp__gsd-workflow__*`, // surfacing "This command requires approval" on every GSD action (#4099). const allowedTools = mcpServers ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) : []; return { pathToClaudeCodeExecutable: getClaudePath(), model: modelId, includePartialMessages: true, persistSession: true, cwd: process.cwd(), permissionMode, allowDangerouslySkipPermissions: permissionMode === "bypassPermissions", settingSources: ["project"], systemPrompt: { type: "preset", preset: "claude_code" }, disallowedTools, ...(allowedTools.length > 0 ? { allowedTools } : {}), ...(mcpServers ? { mcpServers } : {}), betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [], ...extraOptions, }; } function normalizeToolResultContent(content: unknown): ExternalToolResultContentBlock[] { if (typeof content === "string") { return [{ type: "text", text: content }]; } if (!Array.isArray(content)) { if (content == null) return [{ type: "text", text: "" }]; return [{ type: "text", text: JSON.stringify(content) }]; } const blocks: ExternalToolResultContentBlock[] = []; for (const item of content) { if (typeof item === "string") { blocks.push({ type: "text", text: item }); continue; } if (!item || typeof item !== "object") { blocks.push({ type: "text", text: String(item) }); continue; } const block = item as Record; if (block.type === "text") { blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" }); continue; } if ( block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string" ) { blocks.push({ type: "image", data: block.data, mimeType: block.mimeType }); continue; } blocks.push({ type: "text", text: JSON.stringify(block) }); } return blocks.length > 0 ? blocks : [{ type: "text", text: "" }]; } export function extractToolResultsFromSdkUserMessage(message: SDKUserMessage): Array<{ toolUseId: string; result: ExternalToolResultPayload; }> { const extracted: Array<{ toolUseId: string; result: ExternalToolResultPayload }> = []; const seen = new Set(); const rawMessage = message.message as Record | null | undefined; const content = Array.isArray(rawMessage?.content) ? rawMessage.content : []; for (const item of content) { if (!item || typeof item !== "object") continue; const block = item as Record; const type = typeof block.type === "string" ? block.type : ""; if (type !== "tool_result" && type !== "mcp_tool_result") continue; const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; if (!toolUseId || seen.has(toolUseId)) continue; seen.add(toolUseId); extracted.push({ toolUseId, result: { content: normalizeToolResultContent(block.content), details: {}, isError: block.is_error === true, }, }); } if (extracted.length === 0) { const fallback = message.tool_use_result; if (fallback && typeof fallback === "object") { const toolResult = fallback as Record; const toolUseId = typeof toolResult.tool_use_id === "string" ? toolResult.tool_use_id : ""; if (toolUseId) { extracted.push({ toolUseId, result: { content: normalizeToolResultContent(toolResult.content), details: {}, isError: toolResult.is_error === true, }, }); } } } return extracted; } function attachExternalResultsToToolBlocks( toolBlocks: AssistantMessage["content"], toolResultsById: ReadonlyMap, ): void { for (const block of toolBlocks) { if (block.type !== "toolCall" && block.type !== "serverToolUse") continue; const externalResult = toolResultsById.get(block.id); if (!externalResult) continue; (block as ToolCallWithExternalResult & { id: string }).externalResult = externalResult; } } /** * Merge tool-call blocks from the active partial-message builder into the * running list of intermediate tool calls, preserving order and de-duping * by tool-call id. Exposed for testing the F3 fix (final-turn tool calls * dropped when `result` arrives without a preceding synthetic `user`). */ export function mergePendingToolCalls( intermediate: AssistantMessage["content"], pending: AssistantMessage["content"], ): AssistantMessage["content"] { const alreadyIncluded = new Set(); for (const block of intermediate) { if (block.type === "toolCall") alreadyIncluded.add(block.id); } for (const block of pending) { if (block.type !== "toolCall") continue; if (alreadyIncluded.has(block.id)) continue; alreadyIncluded.add(block.id); intermediate.push(block); } return intermediate; } // --------------------------------------------------------------------------- // 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 = ""; /** Collect tool blocks from intermediate SDK turns for tool execution rendering. */ const intermediateToolBlocks: AssistantMessage["content"] = []; /** Preserve real external tool results from Claude Code's synthetic user messages. */ const toolResultsById = new Map(); 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 = buildPromptFromContext(context); const permissionMode = await resolveClaudePermissionMode(); const sdkOpts = buildSdkOptions( modelId, prompt, { permissionMode }, typeof (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext === "object" ? { onElicitation: createClaudeCodeElicitationHandler( (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext, ), } : {}, ); const queryResult = sdk.query({ prompt, options: { ...sdkOpts, abortController: controller, }, }); // 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) { // User-initiated cancel — emit an aborted error so the agent // loop classifies this as a deliberate stop, not a transient // provider failure that should be retried. stream.push({ type: "error", reason: "aborted", error: makeAbortedMessage(modelId, lastTextContent), }); return; } 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; 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; // 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": { // Capture content from the completed turn 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; } else if (block.type === "toolCall" || block.type === "serverToolUse") { // Collect tool blocks for externalToolExecution rendering intermediateToolBlocks.push(block); } } } // Extract tool results from the SDK's synthetic user message // and attach to corresponding tool call blocks immediately. for (const { toolUseId, result } of extractToolResultsFromSdkUserMessage(msg as SDKUserMessage)) { toolResultsById.set(toolUseId, result); } attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById); // Push a synthetic toolcall_end for each tool call from this turn // so the TUI can render tool results in real-time during the SDK // session instead of waiting until the entire session completes. if (builder) { for (const block of builder.message.content) { const extResult = (block as ToolCallWithExternalResult).externalResult; if (!extResult) continue; const contentIndex = builder.message.content.indexOf(block); if (contentIndex < 0) continue; // Push synthetic completion events with result attached so the // chat-controller can update pending ToolExecutionComponents. if (block.type === "toolCall") { stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: builder.message, }); } else if (block.type === "serverToolUse") { stream.push({ type: "server_tool_use", contentIndex, partial: builder.message, }); } } } builder = null; break; } // -- Result (terminal) -- case "result": { const result = msg as SDKResultMessage; // Build final message. Include intermediate tool calls so the // agent loop's externalToolExecution path emits tool_execution // events for proper TUI rendering, followed by the text response. const finalContent: AssistantMessage["content"] = []; // If the final turn ended without a synthetic user message // (e.g. stop_reason: "tool_use" followed directly by result, // or a turn with text but no tool execution), the `builder` // still holds toolCall blocks that were never pushed into // `intermediateToolBlocks`. Fold them in here so they aren't // dropped from the final AssistantMessage. if (builder) { mergePendingToolCalls(intermediateToolBlocks, builder.message.content); } // Add tool calls from intermediate turns first (renders above text) attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById); finalContent.push(...intermediateToolBlocks); // Add text/thinking from the last turn if (builder && builder.message.content.length > 0) { for (const block of builder.message.content) { if (block.type === "text" || block.type === "thinking") { finalContent.push(block); } } } else { 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) { finalMessage.errorMessage = getResultErrorMessage(result); stream.push({ type: "error", reason: "error", error: finalMessage }); } else { stream.push({ type: "done", reason: "stop", message: finalMessage }); } return; } default: break; } } // Generator exhaustion without a terminal result is a stream interruption, // not a successful completion. Emitting an error lets GSD classify it as a // transient provider failure instead of advancing auto-mode state. const fallback = makeStreamExhaustedErrorMessage(modelId, lastTextContent); stream.push({ type: "error", reason: "error", error: fallback }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); stream.push({ type: "error", reason: "error", error: makeErrorMessage(modelId, errorMsg), }); } }