import { type PermissionReply } from "../runtime/opencode.js"; import type { ResponseInputTracker } from "../chat/response-inputs.js"; import type { UIEnvelopePayload } from "../uiEnvelope/types.js"; import { type PermissionRequestPayload, type QuestionRequestPayload, type TodoUpdatePayload, } from "./chatStream.js"; export type RunStatus = "running" | "waiting_input" | "completed" | "error" | "aborted"; export type StreamSubscriber = { write: (event: string, data: Record) => void; close: () => void; }; export type ActiveRun = { applyEvent?: (event: string, data: Record) => void; uiMessages?: () => unknown[]; inputs?: ResponseInputTracker; clientSessionId: string; controller: AbortController; messages: unknown[]; pendingPermissions: Map; pendingQuestions: Map; status: RunStatus; subscribers: Set; }; type ToolArtifactKind = "chart" | "map" | "panel" | "tool"; type ToolCallPayload = { session_id?: string; tool?: string; params?: unknown; reason?: string; }; export const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const createFrontendMessageId = () => `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; export const createInitialStreamingMessages = ( existingMessages: unknown[], userContent: string, ) => { const userMessage = { id: createFrontendMessageId(), role: "user", content: userContent, }; return [ ...existingMessages, { ...userMessage, branchRootId: userMessage.id, }, { id: createFrontendMessageId(), role: "assistant", content: "", progress: [ { id: "request-received", phase: "start", status: "running", title: "已收到请求,正在启动 Agent 分析", detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", startedAt: Date.now(), elapsedMs: 0, elapsedSnapshotAt: Date.now(), }, ], }, ]; }; export const upsertBackendProgress = ( progress: unknown, payload: Record, ) => { const next = Array.isArray(progress) ? [...progress] : []; const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`; const index = next.findIndex((item) => isObjectRecord(item) && item.id === id); const existing = index >= 0 && isObjectRecord(next[index]) ? next[index] : undefined; const tool = isObjectRecord(payload.tool) && typeof payload.tool.name === "string" ? { name: payload.tool.name, callId: typeof payload.tool.call_id === "string" ? payload.tool.call_id : undefined, input: isObjectRecord(payload.tool.input) ? payload.tool.input : undefined } : existing?.tool; const nextItem = { id, tool, phase: typeof payload.phase === "string" ? payload.phase : "progress", status: payload.status === "completed" || payload.status === "error" ? payload.status : "running", title: typeof payload.title === "string" ? payload.title : "正在处理", detail: typeof payload.detail === "string" ? payload.detail : undefined, startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined, endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined, elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined, elapsedSnapshotAt: typeof payload.elapsed_ms === "number" ? Date.now() : undefined, durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined, }; if (index >= 0) { next[index] = nextItem; } else { next.push(nextItem); } return next; }; export const completeBackendProgress = (progress: unknown) => Array.isArray(progress) ? progress.map((item) => { if (!isObjectRecord(item) || item.status !== "running") { return item; } const endedAt = Date.now(); const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined; return { ...item, status: "completed", endedAt, elapsedMs: undefined, elapsedSnapshotAt: undefined, durationMs: typeof item.durationMs === "number" ? item.durationMs : startedAt !== undefined ? Math.max(0, endedAt - startedAt) : item.elapsedMs, }; }) : progress; export const cancelBackendTodos = (todos: unknown) => Array.isArray(todos) ? todos.map((todoUpdate) => { if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) { return todoUpdate; } return { ...todoUpdate, todos: todoUpdate.todos.map((todo) => { if (!isObjectRecord(todo)) { return todo; } if (todo.status !== "pending" && todo.status !== "in_progress") { return todo; } return { ...todo, status: "cancelled", updatedAt: Date.now(), }; }), }; }) : todos; export const updateLastAssistantMessage = ( messages: unknown[], updater: (message: Record) => Record, ) => { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (isObjectRecord(message) && message.role === "assistant") { const next = [...messages]; next[index] = updater(message); return next; } } return messages; }; /** Replies belong to the assistant that requested input, including earlier turns. */ export function applyInteractionResponse( messages: T[], event: "permission_response" | "question_response", data: Record, ): T[] { const requestId = data.request_id; if (typeof requestId !== "string") return messages; const permission = event === "permission_response"; const field = permission ? "permissions" : "questions"; const requestType = permission ? "data-permission_request" : "data-question_request"; let changed = false; const next = messages.map((message) => { if (!isObjectRecord(message) || message.role !== "assistant") return message; const entries = Array.isArray(message[field]) ? message[field] as unknown[] : undefined; const parts = Array.isArray(message.parts) ? [...message.parts] : undefined; const ownsRequest = entries?.some((entry) => isObjectRecord(entry) && entry.requestId === requestId) || parts?.some((part) => isObjectRecord(part) && part.type === requestType && isObjectRecord(part.data) && part.data.request_id === requestId); if (!ownsRequest) return message; changed = true; const updated = entries?.map((entry) => { if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry; return permission ? { ...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply), error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(), } : { ...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered", ...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(), }; }); if (parts && data.status !== "pending") { const type = `data-${event}`; const index = parts.findIndex((part) => isObjectRecord(part) && part.type === type && isObjectRecord(part.data) && part.data.request_id === requestId); const previous = index >= 0 && isObjectRecord(parts[index]) ? parts[index] : undefined; const part = { ...previous, type, data: { ...(isObjectRecord(previous?.data) ? previous.data : {}), ...data } }; if (index >= 0) parts[index] = part; else parts.push(part); } return { ...message, ...(updated ? { [field]: updated } : {}), ...(parts ? { parts } : {}) }; }); return changed ? next : messages; } const getToolArtifactKind = (tool: string): ToolArtifactKind => { if (tool === "present_view") { return "map"; } return "tool"; }; const getToolArtifactTitle = (tool: string, params: Record) => { if (typeof params.title === "string" && params.title.trim()) { return params.title.trim(); } return tool || "工具调用"; }; export const appendBackendToolArtifact = ( artifacts: unknown, payload: ToolCallPayload, ) => { const tool = typeof payload.tool === "string" ? payload.tool.trim() : ""; if (!tool) { return artifacts; } const params = isObjectRecord(payload.params) ? payload.params : {}; const next = Array.isArray(artifacts) ? [...artifacts] : []; next.push({ id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, tool, kind: getToolArtifactKind(tool), title: getToolArtifactTitle(tool, params), description: typeof payload.reason === "string" && payload.reason.trim() ? payload.reason.trim() : undefined, params, }); return next; }; export const appendBackendUiEnvelope = ( uiEnvelopes: unknown, payload: UIEnvelopePayload, ) => { const next = Array.isArray(uiEnvelopes) ? [...uiEnvelopes] : []; next.push({ envelopeId: payload.envelope_id, createdAt: payload.created_at, envelope: payload.envelope, renderStatus: "pending", }); return next; }; export const toFrontendPermission = ( payload: PermissionRequestPayload, status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending", ) => ({ requestId: payload.request_id, sessionId: payload.session_id, permission: payload.permission, patterns: payload.patterns, target: payload.target, always: payload.always, tool: payload.tool, createdAt: payload.created_at, status, }); const toFrontendQuestion = ( payload: QuestionRequestPayload, status: "pending" | "submitting" | "answered" | "rejected" | "error" = "pending", ) => ({ requestId: payload.request_id, sessionId: payload.session_id, questions: payload.questions, tool: payload.tool, createdAt: payload.created_at, status, }); export const toPermissionStatus = (reply: PermissionReply) => { if (reply === "always") return "approved_always"; if (reply === "once") return "approved_once"; return "rejected"; }; export const upsertBackendQuestion = ( questions: unknown, payload: QuestionRequestPayload, ) => { const next = Array.isArray(questions) ? [...questions] : []; const index = next.findIndex((item) => { if (!isObjectRecord(item)) return false; if (item.requestId === payload.request_id) return true; const tool = isObjectRecord(item.tool) ? item.tool : undefined; return Boolean( payload.tool?.callID && tool?.callID === payload.tool.callID, ); }); const nextItem = toFrontendQuestion(payload); if (index >= 0) { const current = next[index]; const currentRequestId = isObjectRecord(current) ? current.requestId : undefined; const currentTool = isObjectRecord(current) && isObjectRecord(current.tool) ? current.tool : undefined; const currentIsActionable = typeof currentRequestId === "string" && currentRequestId !== currentTool?.callID; const payloadIsToolPlaceholder = Boolean(payload.tool?.callID) && payload.request_id === payload.tool?.callID; next[index] = { ...(isObjectRecord(current) ? current : {}), ...(currentIsActionable && payloadIsToolPlaceholder ? { questions: nextItem.questions, tool: nextItem.tool, createdAt: nextItem.createdAt, } : nextItem), status: isObjectRecord(current) && current.status === "submitting" ? "submitting" : nextItem.status, }; } else { next.push(nextItem); } return next; }; export const upsertBackendTodoUpdate = ( _todos: unknown, payload: TodoUpdatePayload, ) => [ { sessionId: payload.session_id, messageId: payload.message_id, todos: payload.todos, createdAt: payload.created_at, }, ];