import type { AgentPromptAttachment } from "@agent-native/toolkit/composer"; import { unwrapAttachmentEnvelope } from "@agent-native/toolkit/composer/pasted-text"; import type { ChatModelAdapter, ChatModelRunOptions, ChatModelRunResult, } from "@assistant-ui/react"; import { normalizeCodeAgentTranscript, type CodeAgentTranscriptEvent as CoreCodeAgentTranscriptEvent, type NormalizedCodeAgentStatusEvent, type NormalizedCodeAgentThinkingEvent, type NormalizedCodeAgentToolEvent, type NormalizedCodeAgentTranscriptItem, } from "../code-agents/transcript-normalizer.js"; import { compareCodeAgentTranscriptEvents, isCodeAgentRunActive, type CodeAgentRunStateLike, } from "../code-agents/transcript-order.js"; import type { ReasoningEffort } from "../shared/reasoning-effort.js"; import type { ContentPart } from "./sse-event-processor.js"; export type CodeAgentChatFollowUpMode = "immediate" | "queued"; export interface CodeAgentChatTranscriptEvent { id: string; runId: string; kind?: CoreCodeAgentTranscriptEvent["kind"]; type?: CoreCodeAgentTranscriptEvent["kind"] | "note"; message?: string; text?: string; createdAt: string; metadata?: Record; artifactPath?: string; artifactUrl?: string; } export interface CodeAgentChatControlResult { ok: boolean; run?: CodeAgentRunStateLike | null; queued?: boolean; message?: string; error?: string; } export interface CodeAgentChatController { get(runId: string): Promise; transcript(runId: string): Promise; sendFollowUp(input: { runId: string; prompt: string; mode?: CodeAgentChatFollowUpMode; permissionMode?: string; engine?: string; model?: string; reasoningEffort?: ReasoningEffort; source?: string; metadata?: Record; }): Promise; control(input: { runId: string; /** * `"approve"` resolves the run's current pending `needsApproval` gate * (see `requestCodeAgentApproval` in `cli/code-agent-executor.ts`) — the * same effect as the host UI's "Approve" control. Deny / always-allow are * intentionally NOT routed through this method; hosts wire those directly * via `AssistantChatProps.approvalActions` instead (see CodeAgentsApp). */ command: "stop" | "approve"; }): Promise; } export interface CreateCodeAgentChatAdapterOptions { controller: CodeAgentChatController; runIdRef: { current: string | null }; permissionModeRef?: { current: string | undefined }; modelRef?: { current: string | undefined }; engineRef?: { current: string | undefined }; effortRef?: { current: ReasoningEffort | undefined }; followUpModeRef?: { current: CodeAgentChatFollowUpMode | undefined }; attachOnlyRef?: { current: boolean }; tabId?: string; pollIntervalMs?: number; idlePollIntervalMs?: number; terminalIdlePolls?: number; /** * Assistant-ui may abort a run for UI lifecycle reasons, such as switching * selected sessions. Code sessions keep running unless the host sends an * explicit stop command. */ stopOnAbort?: boolean; } type AssistantUiAttachment = { name?: string; contentType?: string; content?: readonly Record[]; }; export function createCodeAgentChatAdapter( options: CreateCodeAgentChatAdapterOptions, ): ChatModelAdapter { const pollIntervalMs = options.pollIntervalMs ?? 1000; const idlePollIntervalMs = options.idlePollIntervalMs ?? 5000; const terminalIdlePolls = options.terminalIdlePolls ?? 3; const stopOnAbort = options.stopOnAbort === true; return { async *run({ messages, abortSignal, runConfig }: ChatModelRunOptions) { const runId = options.runIdRef.current; if (!runId) { yield errorResult("Select an Agent-Native Code session first."); return; } // Human-in-the-loop: `ApprovalContext.onApprove` (tool-call-display.tsx) // re-issues the turn carrying the pending approval's key in // `approvedToolCalls`, the same mechanism regular SSE agent-chat // approvals use. For Code sessions there is no server-side gate to // notify — resolve the run's own pending approval directly instead of // treating the accompanying "Approved..." text as a new prompt. const approvedToolCalls = extractApprovedToolCalls(runConfig); const isApprovalTurn = Boolean( approvedToolCalls && approvedToolCalls.length > 0, ); const lastUserMessage = latestUserMessage(messages); const attachments = lastUserMessage ? extractPromptAttachmentsFromAssistantMessage(lastUserMessage) : []; const prompt = latestUserText(messages).trim() || (attachments.length > 0 ? "Use the attached context." : ""); if (!isApprovalTurn && !prompt.trim()) { yield errorResult("Enter a follow-up prompt."); return; } let stoppedFromAbort = false; const stopForAbort = () => { stoppedFromAbort = true; if (stopOnAbort) { void options.controller.control({ runId, command: "stop" }); } }; if (abortSignal.aborted) { stopForAbort(); return; } abortSignal.addEventListener("abort", stopForAbort, { once: true }); try { const initialEvents = await options.controller.transcript(runId); const seenEventIds = new Set(initialEvents.map((event) => event.id)); const tailedEvents: CodeAgentChatTranscriptEvent[] = []; if (isApprovalTurn) { const response = await options.controller.control({ runId, command: "approve", }); if (!response.ok) { yield errorResult( response.error ?? response.message ?? "Could not approve.", runId, ); return; } } else if (!options.attachOnlyRef?.current) { const beforeSendRun = await options.controller.get(runId); const response = await options.controller.sendFollowUp({ runId, prompt, mode: options.followUpModeRef?.current ?? (beforeSendRun && isCodeAgentRunActive(beforeSendRun) ? "queued" : "immediate"), permissionMode: options.permissionModeRef?.current, engine: options.engineRef?.current, model: options.modelRef?.current, reasoningEffort: options.effortRef?.current, source: "code-agent-chat", metadata: attachments.length > 0 ? { attachments } : undefined, }); if (!response.ok) { yield errorResult( response.error ?? response.message ?? "Could not send follow-up.", runId, ); return; } } let yieldedContent = false; let idleTerminalPolls = 0; while (!abortSignal.aborted) { const [events, run] = await Promise.all([ options.controller.transcript(runId), options.controller.get(runId), ]); const nextEvents = events .filter((event) => !seenEventIds.has(event.id)) .sort(compareCodeAgentTranscriptEvents); for (const event of nextEvents) { seenEventIds.add(event.id); tailedEvents.push(event); } const content = codeAgentTranscriptEventsToContent(tailedEvents); const sawClear = nextEvents.some(isAgentChatClearTranscriptEvent); if ((content.length > 0 || sawClear) && nextEvents.length > 0) { yieldedContent = true; yield withRunMetadata({ content: [...content] }, runId); } if (run && isCodeAgentRunActive(run)) { idleTerminalPolls = 0; } else if (nextEvents.length === 0) { idleTerminalPolls += 1; } else { idleTerminalPolls = 0; } if (idleTerminalPolls >= terminalIdlePolls) { if (content.length > 0 && !yieldedContent) { yield withRunMetadata({ content: [...content] }, runId); } return; } await sleep( run && isCodeAgentRunActive(run) ? pollIntervalMs : idlePollIntervalMs, abortSignal, ); } } finally { abortSignal.removeEventListener("abort", stopForAbort); if (stoppedFromAbort) { return; } } }, }; } function extractApprovedToolCalls( runConfig: ChatModelRunOptions["runConfig"] | undefined, ): string[] | undefined { const raw = runConfig?.custom && typeof runConfig.custom === "object" && "approvedToolCalls" in runConfig.custom ? (runConfig.custom as { approvedToolCalls?: unknown }).approvedToolCalls : undefined; if (!Array.isArray(raw)) return undefined; const keys = raw.filter( (key): key is string => typeof key === "string" && key.length > 0, ); return keys.length > 0 ? keys : undefined; } /** * Whether any tool-call in this Code transcript currently carries an * unresolved approval (see `NormalizedCodeAgentToolEvent.pendingApprovalKey`). * Hosts (e.g. `CodeAgentsApp`) use this to keep their standalone approval * banner as a fallback only for transcripts where the inline * `ApprovalAffordance` cannot be joined to a tool-call part, instead of * showing both at once. */ export function codeAgentTranscriptHasPendingApproval( events: readonly CodeAgentChatTranscriptEvent[], ): boolean { const normalized = normalizeCodeAgentTranscript( events.map(toCoreCodeAgentTranscriptEvent), ); return normalized.items.some( (item) => item.type === "tool" && Boolean(item.pendingApprovalKey), ); } export function codeAgentTranscriptEventsToContent( events: readonly CodeAgentChatTranscriptEvent[], ): ContentPart[] { const normalized = normalizeCodeAgentTranscript( events.map(toCoreCodeAgentTranscriptEvent), ); const content: ContentPart[] = []; const appendText = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; const last = content.at(-1); if (last?.type === "text") { last.text = `${last.text}${last.text ? "\n\n" : ""}${trimmed}`; } else { content.push({ type: "text", text: trimmed }); } }; for (const item of normalized.items) { const part = contentPartForCodeAgentTranscriptItem(item); if (!part) continue; if (part.type === "text") { appendText(part.text); } else { content.push(part); } } return content; } function isAgentChatClearTranscriptEvent( event: CodeAgentChatTranscriptEvent | undefined, ): boolean { return event?.metadata?.agentChatEventType === "clear"; } function latestUserMessage( messages: ChatModelRunOptions["messages"], ): ChatModelRunOptions["messages"][number] | undefined { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message.role === "user") return message; } return undefined; } function latestUserText(messages: ChatModelRunOptions["messages"]): string { const message = latestUserMessage(messages); return ( message?.content .filter( (part): part is { type: "text"; text: string } => part.type === "text", ) .map((part) => part.text) .join("\n") ?? "" ); } function extractPromptAttachmentsFromAssistantMessage(message: { attachments?: readonly AssistantUiAttachment[]; }): AgentPromptAttachment[] { const attachments: AgentPromptAttachment[] = []; for (const att of message.attachments ?? []) { const name = att.name ?? "attachment"; for (const part of att.content ?? []) { if (part.type === "image" && typeof part.image === "string") { attachments.push({ name, type: att.contentType, dataUrl: part.image, }); } else if (part.type === "file" && typeof part.data === "string") { attachments.push({ name, type: att.contentType ?? (typeof part.mimeType === "string" ? part.mimeType : undefined), ...(part.data.startsWith("data:") ? { dataUrl: part.data } : { text: part.data }), }); } else if (part.type === "text" && typeof part.text === "string") { attachments.push({ name, type: att.contentType, text: unwrapAttachmentEnvelope(part.text), }); } } } return attachments; } function toCoreCodeAgentTranscriptEvent( event: CodeAgentChatTranscriptEvent, ): CoreCodeAgentTranscriptEvent { return { schemaVersion: 1, id: event.id, runId: event.runId, kind: (event.kind ?? event.type ?? "status") as CoreCodeAgentTranscriptEvent["kind"], message: event.message ?? event.text ?? "", createdAt: event.createdAt, metadata: { ...(event.metadata ?? {}), ...(event.artifactPath ? { artifactPath: event.artifactPath } : {}), ...(event.artifactUrl ? { artifactUrl: event.artifactUrl } : {}), }, }; } function contentPartForCodeAgentTranscriptItem( item: NormalizedCodeAgentTranscriptItem, ): ContentPart | null { if (item.type === "assistant") { return item.text.trim() ? { type: "text", text: item.text.trim() } : null; } if (item.type === "tool") { return toolContentPartForCodeAgentTranscriptItem(item); } if (item.type === "thinking") { return thinkingContentPartForCodeAgentTranscriptItem(item); } if (item.type === "status") { const text = statusTextForCodeAgentTranscriptItem(item); return text ? { type: "text", text } : null; } return null; } function thinkingContentPartForCodeAgentTranscriptItem( item: NormalizedCodeAgentThinkingEvent, ): ContentPart | null { const text = item.text.trim(); if (!text) return null; return { type: "reasoning", text, }; } function toolContentPartForCodeAgentTranscriptItem( item: NormalizedCodeAgentToolEvent, ): ContentPart { return { type: "tool-call", toolCallId: `code-tool-${item.id}`, toolName: item.tool ?? item.label ?? "code-agent", argsText: previewValue(item.input) ?? "", args: recordArgs(item.input), ...(item.result !== undefined ? { result: previewValue(item.result) ?? "" } : {}), ...(item.mcpApp ? { mcpApp: item.mcpApp } : {}), ...(item.structuredMeta ? { structuredMeta: item.structuredMeta } : {}), ...(item.pendingApprovalKey ? { approval: { approvalKey: item.pendingApprovalKey } } : {}), }; } function statusTextForCodeAgentTranscriptItem( item: NormalizedCodeAgentStatusEvent, ): string | null { if (item.statusKind === "artifact") { const event = item.events[0]; const path = stringMetadata(event?.metadata, "artifactPath") ?? stringMetadata(event?.metadata, "path"); const url = stringMetadata(event?.metadata, "artifactUrl"); const target = url ?? path; return target ? `Artifact: ${item.text}\n${target}` : `Artifact: ${item.text}`; } if (item.level === "info" && item.statusKind !== "note") return null; return item.text; } function recordArgs(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const result: Record = {}; for (const [key, entry] of Object.entries(value)) { result[key] = typeof entry === "string" ? entry : (previewValue(entry) ?? ""); } return result; } function previewValue(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? ""); const trimmed = text.trim(); if (!trimmed) return undefined; return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}\n...` : trimmed; } function stringMetadata( metadata: Record | undefined, key: string, ): string | undefined { const value = metadata?.[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; } function withRunMetadata( result: ChatModelRunResult, runId: string, ): ChatModelRunResult { const metadata = (result.metadata ?? {}) as Record; const custom = metadata.custom && typeof metadata.custom === "object" ? (metadata.custom as Record) : {}; return { ...result, metadata: { ...metadata, custom: { ...custom, runId }, }, }; } function errorResult(message: string, runId?: string): ChatModelRunResult { return withRunMetadata( { content: [{ type: "text", text: message }], status: { type: "incomplete", reason: "error" }, } as ChatModelRunResult, runId ?? "code-agent", ); } function sleep(ms: number, abortSignal: AbortSignal): Promise { if (abortSignal.aborted) return Promise.resolve(); return new Promise((resolve) => { const timeout = setTimeout(resolve, ms); abortSignal.addEventListener( "abort", () => { clearTimeout(timeout); resolve(); }, { once: true }, ); }); }