singularity-forge/src/resources/extensions/claude-code-cli/stream-adapter.ts
Jeremy b92fdc7b6f fix(claude-code): pre-authorize workflow MCP tools so interactive acceptEdits mode stops blocking GSD commands
Since 2.72.0 the interactive permission default is acceptEdits, which
auto-approves built-in Edit/Write/Bash but leaves the SDK permission
gate up for MCP tools. Without a canUseTool handler, every
mcp__gsd-workflow__* call surfaces as "This command requires approval"
and blocks GSD actions (#4099).

Add allowedTools entries (mcp__<server>__*) for each registered workflow
MCP server in buildSdkOptions so they run unattended while the rest of
the acceptEdits safety gate stays intact. Env-overridden server names
are handled by deriving the glob list from the built mcpServers keys.

Fixes #4099
2026-04-13 06:34:59 -05:00

1053 lines
34 KiB
TypeScript

/**
* 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<string, unknown>;
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<string, SdkElicitationFieldSchema>;
required?: string[];
};
}
interface SdkElicitationResult {
action: "accept" | "decline" | "cancel";
content?: Record<string, string | string[]>;
}
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<AssistantMessageEvent, AssistantMessage>(
(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 <user_message>, <assistant_message>, or <prior_system_context> tags in your response.",
];
if (context.systemPrompt) {
parts.push(`<prior_system_context>\n${context.systemPrompt}\n</prior_system_context>`);
}
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</${tag}>`);
}
if (turns.length > 0) {
parts.push(`<conversation_history>\n${turns.join("\n")}\n</conversation_history>`);
}
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<SdkElicitationRequest, "mode" | "requestedSchema">,
): 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<string, unknown>;
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<SdkElicitationRequest, "message" | "mode" | "requestedSchema">,
): ParsedTextInputField[] | null {
if (request.mode && request.mode !== "form") return null;
const schema = request.requestedSchema as
| ({ properties?: Record<string, SdkElicitationFieldSchema>; keys?: Record<string, SdkElicitationFieldSchema> } & Record<string, unknown>)
| 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<string, string | string[]> {
const content: Record<string, string | string[]> = {};
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<SdkElicitationResult> {
const content: Record<string, string | string[]> = {};
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<SdkElicitationResult> {
const content: Record<string, string | string[]> = {};
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<SdkElicitationResult>) | 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<string, unknown> = {},
): Record<string, unknown> {
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<string, unknown>;
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<string>();
const rawMessage = message.message as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, ExternalToolResultPayload>,
): 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<string>();
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<any>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = createAssistantStream();
void pumpSdkMessages(model, context, options, stream);
return stream;
}
async function pumpSdkMessages(
model: Model<any>,
context: Context,
options: SimpleStreamOptions | undefined,
stream: AssistantMessageEventStream,
): Promise<void> {
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<string, ExternalToolResultPayload>();
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<unknown>;
options?: Record<string, unknown>;
}) => AsyncIterable<SDKMessage>;
};
// 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<SDKMessage>) {
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),
});
}
}