import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import { canAutomaticallyApprove } from "./chatPermissionPolicy.js"; import { createBusinessStageTracker } from "./chatBusinessStages.js"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { type SupportedModel } from "../chat/models.js"; import { toRuntimeModel } from "../chat/modelConfig.js"; import { config } from "../config.js"; import { executionPlanSchema } from "./executionPlan.js"; import { logger } from "../logger.js"; import { type PermissionReply, type OpencodeRuntimeAdapter, } from "../runtime/opencode.js"; import { buildPermissionDetail, buildPermissionV2Detail, buildReasoningProgressDetail, buildSessionStatusDetail, buildToolProgressDetail, collectTextContent, extractRequestReason, extractSkillAuditInfo, getErrorMessage, getToolProgressTitle, getUnknownErrorMessage, hasToolParams, isPermissionAskedEvent, isPermissionRepliedEvent, isPermissionV2AskedEvent, isPermissionV2RepliedEvent, isQuestionAskedEvent, isObjectRecord, isQuestionRejectedEvent, isQuestionRepliedEvent, isQuestionV2AskedEvent, isQuestionV2RejectedEvent, isQuestionV2RepliedEvent, isSessionEvent, isSkillEvent, logDevelopmentDebug, normalizeQuestionAnswers, normalizeQuestionPayload, normalizeQuestionToolPayload, normalizeTodoPriority, normalizeTodoStatus, normalizeToolParams, sanitizeToolProgressInput, normalizeToolStatus, type PermissionRequestPayload, type TodoUpdatePayload, } from "./chatStreamEvents.js"; import { createTokenSmoother } from "./chatTokenSmoother.js"; import { AnswerUiPublication } from "../chat/answer-ui-publication.js"; import { parseAnswerUiInput } from "../chat/answer-ui.js"; export { createTokenSmoother, detectTokenSmoothingChunk, } from "./chatTokenSmoother.js"; export { collectTextContent, type PermissionRequestPayload, type QuestionRequestPayload, type TodoItemPayload, type TodoUpdatePayload, } from "./chatStreamEvents.js"; export type ApprovalMode = "request" | "always"; type StreamPromptOptions = { interactionMessageId?: string; onHostQuestion?: (interactionId: string) => Promise>; onPromptDispatched?: () => void; onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void; waitForUserInput?: () => Promise; beforePrompt?: () => Promise; beforeIdle?: () => Promise; beforeAnswerClose?: () => Promise; runtime: OpencodeRuntimeAdapter; sessionId: string; clientSessionId: string; message: string; model?: SupportedModel; approvalMode?: ApprovalMode; traceId?: string; projectId?: string; signal?: AbortSignal; suppressAnswerUi?: boolean; write: (event: string, data: Record) => void; }; type ProgressStatus = "running" | "completed" | "error"; type ProgressPayload = { id: string; phase: string; status: ProgressStatus; title: string; detail?: string; tool?: { name: string; call_id?: string; input?: Record }; }; const getPermissionTarget = (metadata: unknown) => { if (!isObjectRecord(metadata)) { return undefined; } for (const key of ["command", "path", "file", "filepath", "directory"]) { const value = metadata[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return undefined; }; const parseToolOutput = (output: unknown): unknown => { if (typeof output !== "string") return output; try { return JSON.parse(output); } catch { return null; } }; const emitFallbackMessage = async ( runtime: OpencodeRuntimeAdapter, sessionId: string, clientSessionId: string, writeToken: (content: string) => void, ) => { const messages = await runtime.messages(sessionId); const assistantMessage = [...messages] .reverse() .find((message) => message.info.role === "assistant"); const parts = assistantMessage?.parts ?? []; const text = collectTextContent(parts); if (text) { writeToken(text); } }; export const streamPromptResponse = async ({ runtime, sessionId, clientSessionId, message, model, approvalMode = "request", traceId, projectId, signal, suppressAnswerUi = false, write, beforePrompt, beforeIdle, beforeAnswerClose, onPromptDispatched, onInboxEvent, waitForUserInput, interactionMessageId, onHostQuestion, }: StreamPromptOptions): Promise<{ aborted: boolean; failed: boolean; toolCallCount: number; waitingInput?: boolean; }> => { const eventStream = await runtime.subscribeEvents(); const iterator = eventStream[Symbol.asyncIterator](); const requestStartedAt = Date.now(); const promptStartedAt = Date.now(); const progressStartedAtMap = new Map(); const finalizedProgressIds = new Set(); const emittedToolParts = new Set(); const emittedQuestionToolParts = new Set(); const emittedQuestionRequestIds = new Set(); const partTypes = new Map(); const pendingPartTextDeltas = new Map(); const reasoningDeltas = new Map(); const reasoningStatuses = new Map(); const toolStatuses = new Map(); let firstSessionEventLogged = false; let firstNonStatusEventLogged = false; let firstTokenLogged = false; let firstReasoningLogged = false; let firstToolEventLogged = false; let lastSessionStatus: string | null = null; let lastSessionStatusMessage: string | null = null; let sawResponseActivity = false; let emittedText = false; let toolCallCount = 0; let done = false; let promptSettled = false; let aborted = signal?.aborted ?? false; let failed = false; let waitingInput = false; let finalAnswer: string | undefined; let finalAnswerRefs: unknown; let finalAnswerPublished = false; const answerUiPublication = new AnswerUiPublication(); const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId })); const debugContext = { sessionId, clientSessionId, traceId, projectId, model: model ?? null, }; const tokenSmoother = createTokenSmoother({ enabled: config.AGENT_TOKEN_SMOOTHING_ENABLED, delayMs: config.AGENT_TOKEN_SMOOTHING_DELAY_MS, locale: config.AGENT_TOKEN_SMOOTHING_LOCALE, sessionId: clientSessionId, write: (event, data) => { if (event === "token" && !firstTokenLogged) { firstTokenLogged = true; logDevelopmentDebug("first response token emitted", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), }); } write(event, data); }, }); const publishFinalAnswer = async () => { if (!finalAnswer || finalAnswerPublished) return; await tokenSmoother.flush(); write("final_answer", { session_id: clientSessionId, content: finalAnswer, ui_block_ids: answerUiPublication.finalize(finalAnswerRefs), }); finalAnswerPublished = true; }; const tryCompleteAnswer = async () => { if (!finalAnswer || suppressAnswerUi || signal?.aborted) return; if ([...toolStatuses.values()].some((status) => status !== "completed" && status !== "error")) return; if (beforeAnswerClose ? !await beforeAnswerClose() : waitForUserInput && await waitForUserInput()) return; if (signal?.aborted) return; // A validated answer is readable before native cleanup. Chat completion is // independent of Server-owned task completion and native inbox admission. await publishFinalAnswer(); await tokenSmoother.close(); await runtime.abortSession(sessionId); await runtime.waitForSessionIdle(sessionId); done = true; }; logDevelopmentDebug("chat stream started", { ...debugContext, messageChars: message.length, }); const abortPromise = signal ? new Promise<{ type: "abort" }>((resolve) => { const interrupt = () => { // Stage draining may currently be awaiting paced output. Interrupt // that drain as well, so cancellation never waits for UI pacing. void tokenSmoother.close({ immediate: true }); resolve({ type: "abort" }); }; if (signal.aborted) { interrupt(); return; } signal.addEventListener("abort", interrupt, { once: true }); }) : null; const emitProgress = ({ id, phase, status, title, detail, tool }: ProgressPayload) => { if (status === "running" && finalizedProgressIds.has(id)) { return; } const now = Date.now(); const startedAt = progressStartedAtMap.get(id) ?? now; if (!progressStartedAtMap.has(id)) { progressStartedAtMap.set(id, startedAt); } if (status === "running") { write("progress", { session_id: clientSessionId, id, phase, status, title, detail, tool, started_at: startedAt, elapsed_ms: Math.max(0, now - startedAt), }); return; } const durationMs = Math.max(0, now - startedAt); finalizedProgressIds.add(id); progressStartedAtMap.delete(id); write("progress", { session_id: clientSessionId, id, phase, status, title, detail, tool, started_at: startedAt, ended_at: now, duration_ms: durationMs, }); }; emitProgress({ id: "request-received", phase: "start", status: "running", title: "已收到请求,正在启动 Agent 分析", detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", }); const promptPromise = Promise.resolve().then(async () => { await beforePrompt?.(); if (interactionMessageId) { const result = await runtime.continueInteraction(sessionId, interactionMessageId, message, toRuntimeModel(model), signal); if (result === "completed") { const recovered = await runtime.readInteractionResult(sessionId, interactionMessageId); tokenSmoother.writeToken(recovered); emittedText = true; done = true; } } else await runtime.prompt(sessionId, message, toRuntimeModel(model)); onPromptDispatched?.(); }) .then(() => { promptSettled = true; logDevelopmentDebug("runtime.prompt resolved", { ...debugContext, elapsedMs: Math.max(0, Date.now() - promptStartedAt), }); }) .catch((error: unknown) => { promptSettled = true; logDevelopmentDebug("runtime.prompt failed", { ...debugContext, elapsedMs: Math.max(0, Date.now() - promptStartedAt), error: getUnknownErrorMessage(error), }); throw error; }); logDevelopmentDebug("runtime.prompt dispatched", { ...debugContext, }); let pendingEvent: Promise<{ type: "event"; result: IteratorResult }> | null = null; try { while (!done) { if (signal?.aborted) { aborted = true; logDevelopmentDebug("chat stream noticed abort signal", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); break; } // V2 prompt() acknowledges inbox admission before generation. Keep the same // pending read if that acknowledgement wins the race, so no event is consumed unseen. const nextEvent = pendingEvent ??= iterator .next() .then((result) => ({ type: "event" as const, result })); const nextPrompt = promptSettled ? null : promptPromise.then( () => ({ type: "prompt" as const }), (error: unknown) => ({ type: "prompt-error" as const, error }), ); const next = await Promise.race( [ ...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]), ...(abortPromise ? [abortPromise] : []), ], ); if (next.type === "abort") { aborted = true; break; } if (next.type === "prompt-error") { throw next.error; } if (next.type === "prompt") { continue; } pendingEvent = null; if (next.result.done) { break; } const event = next.result.value as OpencodeEvent; if (!isSessionEvent(event, sessionId)) { continue; } if (isPermissionAskedEvent(event) || isPermissionV2AskedEvent(event) || isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) { await tokenSmoother.flush(); if (signal?.aborted) { aborted = true; break; } } const inboxEvent = event as unknown as { type: string; properties: { inboxID?: string; delivery?: "steer" | "queue" } }; if (inboxEvent.type.startsWith("session.inbox.")) { onInboxEvent?.(inboxEvent.type, inboxEvent.properties); continue; } if (!firstSessionEventLogged) { firstSessionEventLogged = true; logDevelopmentDebug("first session event received", { ...debugContext, eventType: event.type, elapsedMs: Math.max(0, Date.now() - requestStartedAt), sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), }); } if (event.type === "session.status") { const nextStatus = event.properties.status.type; const nextStatusMessage = "message" in event.properties.status && typeof event.properties.status.message === "string" ? event.properties.status.message : null; if ( nextStatus !== lastSessionStatus || nextStatusMessage !== lastSessionStatusMessage ) { lastSessionStatus = nextStatus; lastSessionStatusMessage = nextStatusMessage; logDevelopmentDebug("session status updated", { ...debugContext, status: nextStatus, statusMessage: nextStatusMessage, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); } emitProgress({ id: "session-status", phase: "session", status: event.properties.status.type === "idle" ? "completed" : "running", title: event.properties.status.type === "retry" ? `模型请求重试中:${event.properties.status.message}` : event.properties.status.type === "busy" ? "Agent 正在处理请求" : "Agent 已空闲", detail: buildSessionStatusDetail(event.properties.status), }); continue; } if (!firstNonStatusEventLogged) { firstNonStatusEventLogged = true; logDevelopmentDebug("first non-status session event received", { ...debugContext, eventType: event.type, elapsedMs: Math.max(0, Date.now() - requestStartedAt), sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), }); } if (isPermissionAskedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("permission request received", { ...debugContext, requestId: event.properties.id, permission: event.properties.permission, patterns: event.properties.patterns, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission); emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", status: autoApprove ? "completed" : "running", title: autoApprove ? "已自动允许权限请求" : "等待权限确认", detail: autoApprove ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" : buildPermissionDetail(event), }); if (autoApprove) { await runtime.replyPermission({ requestId: event.properties.id, sessionId, // Automatic approval applies only while this round is active. // Native "always" would also authorize later manual rounds. reply: "once", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, reply: "once" satisfies PermissionReply, }); continue; } write("permission_request", { session_id: clientSessionId, request_id: event.properties.id, permission: event.properties.permission, patterns: event.properties.patterns, target: getPermissionTarget(event.properties.metadata), always: event.properties.always, tool: event.properties.tool, created_at: Date.now(), } satisfies PermissionRequestPayload); continue; } if (isPermissionV2AskedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("permission v2 request received", { ...debugContext, requestId: event.properties.id, action: event.properties.action, resources: event.properties.resources, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action); emitProgress({ id: `permission-${event.properties.id}`, phase: "permission", status: autoApprove ? "completed" : "running", title: autoApprove ? "已自动允许权限请求" : "等待权限确认", detail: autoApprove ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" : buildPermissionV2Detail(event), }); if (autoApprove) { await runtime.replyPermission({ requestId: event.properties.id, sessionId, reply: "once", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.id, reply: "once" satisfies PermissionReply, }); continue; } write("permission_request", { session_id: clientSessionId, request_id: event.properties.id, permission: event.properties.action, patterns: event.properties.resources, target: getPermissionTarget(event.properties.metadata), always: event.properties.save ?? [], tool: undefined, created_at: Date.now(), } satisfies PermissionRequestPayload); continue; } if (isPermissionRepliedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("permission request replied", { ...debugContext, requestId: event.properties.requestID, reply: event.properties.reply, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `permission-${event.properties.requestID}`, phase: "permission", status: event.properties.reply === "reject" ? "error" : "completed", title: event.properties.reply === "reject" ? "权限请求已拒绝" : "权限请求已允许", detail: event.properties.reply === "always" ? "已允许本次请求,并记住同类权限。" : event.properties.reply === "once" ? "已允许本次请求。" : "已拒绝本次请求。", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.requestID, reply: event.properties.reply satisfies PermissionReply, }); continue; } if (isPermissionV2RepliedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("permission v2 request replied", { ...debugContext, requestId: event.properties.requestID, reply: event.properties.reply, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `permission-${event.properties.requestID}`, phase: "permission", status: event.properties.reply === "reject" ? "error" : "completed", title: event.properties.reply === "reject" ? "权限请求已拒绝" : "权限请求已允许", detail: event.properties.reply === "always" ? "已允许本次请求,并记住同类权限。" : event.properties.reply === "once" ? "已允许本次请求。" : "已拒绝本次请求。", }); write("permission_response", { session_id: clientSessionId, request_id: event.properties.requestID, reply: event.properties.reply satisfies PermissionReply, }); continue; } if (isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request received", { ...debugContext, requestId: event.properties.id, questionCount: event.properties.questions.length, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `question-${event.properties.id}`, phase: "question", status: "running", title: "等待用户补充信息", detail: event.properties.questions .map((question) => question.question) .join("\n"), }); const payload = normalizeQuestionPayload(event, clientSessionId); emittedQuestionRequestIds.add(payload.request_id); write("question_request", payload); continue; } if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request replied", { ...debugContext, requestId: event.properties.requestID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `question-${event.properties.requestID}`, phase: "question", status: "completed", title: "已收到补充信息", detail: normalizeQuestionAnswers(event.properties.answers) .map((answer) => answer.join("、")) .filter(Boolean) .join("\n"), }); write("question_response", { session_id: clientSessionId, request_id: event.properties.requestID, answers: normalizeQuestionAnswers(event.properties.answers), }); continue; } if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request rejected", { ...debugContext, requestId: event.properties.requestID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `question-${event.properties.requestID}`, phase: "question", status: "completed", title: "已跳过补充信息", detail: "用户选择跳过本次补充信息。", }); write("question_response", { session_id: clientSessionId, request_id: event.properties.requestID, rejected: true, }); continue; } if (isSkillEvent(event)) { sawResponseActivity = true; const { name, reason, payload } = extractSkillAuditInfo(event); logDevelopmentDebug("skill event received", { ...debugContext, skill: name, reason: reason || null, payloadKeys: Object.keys(payload).slice(0, 8), elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); void writeLlmRequestAuditLog({ kind: "skill", sessionId: sessionId, clientSessionId, traceId, projectId, target: name, reason, reasonProvided: Boolean(reason), payload, }).catch((error) => { logger.warn({ err: error }, "failed to write skill audit log"); }); } if (event.type === "message.updated") { if (event.properties.info.role === "assistant") { sawResponseActivity = true; } continue; } if (event.type === "message.part.delta" && event.properties.field === "text") { sawResponseActivity = true; const partType = partTypes.get(event.properties.partID); if (partType === "text") { emittedText = true; tokenSmoother.writeToken(event.properties.delta); } else if (partType === "reasoning") { if (!firstReasoningLogged) { firstReasoningLogged = true; logDevelopmentDebug("first reasoning delta received", { ...debugContext, partId: event.properties.partID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), }); } const pending = reasoningDeltas.get(event.properties.partID) ?? []; pending.push(event.properties.delta); reasoningDeltas.set(event.properties.partID, pending); } else if (!partType) { const pending = pendingPartTextDeltas.get(event.properties.partID) ?? []; pending.push(event.properties.delta); pendingPartTextDeltas.set(event.properties.partID, pending); } continue; } if (event.type === "message.part.updated") { sawResponseActivity = true; const part = event.properties.part; partTypes.set(part.id, part.type); if (part.type === "text") { const pending = pendingPartTextDeltas.get(part.id) ?? []; pendingPartTextDeltas.delete(part.id); for (const content of pending) { emittedText = true; tokenSmoother.writeToken(content); } if (part.time?.end) await tokenSmoother.flush(); } else if (part.type === "reasoning") { const pending = pendingPartTextDeltas.get(part.id) ?? []; if (pending.length > 0) { const existing = reasoningDeltas.get(part.id) ?? []; reasoningDeltas.set(part.id, existing.concat(pending)); } pendingPartTextDeltas.delete(part.id); const reasoningStatus = part.time.end ? "completed" : "running"; if (reasoningStatuses.get(part.id) !== reasoningStatus) { reasoningStatuses.set(part.id, reasoningStatus); logDevelopmentDebug("reasoning part status changed", { ...debugContext, partId: part.id, status: reasoningStatus, chunkCount: (reasoningDeltas.get(part.id) ?? []).length, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); } const reasoningDetail = buildReasoningProgressDetail( reasoningDeltas.get(part.id) ?? [], part.time.end, ); emitProgress({ id: part.id, phase: "planning", status: part.time.end ? "completed" : "running", title: part.time.end ? "分析规划完成" : "正在规划分析步骤", detail: reasoningDetail, }); } if (part.type === "tool") { // A tool may block for seconds. Release narration before entering it, // including publication and question tools with their own lifecycle. await tokenSmoother.flush(); if (signal?.aborted) { aborted = true; break; } const nextToolStatus = String(part.state.status); const previousToolStatus = toolStatuses.get(part.id); toolStatuses.set(part.id, nextToolStatus); if (part.tool === "ask_user") { // Reserve every lifecycle state before generic tool bookkeeping: // a running call must not consume its completed-question identity. if (part.state.status === "completed" && !emittedToolParts.has(part.id)) { const output = parseToolOutput(part.state.output); if (!onHostQuestion || !isObjectRecord(output) || typeof output.interaction_id !== "string") { throw new Error("Host question completed without a valid persisted interaction"); } emittedToolParts.add(part.id); const record = await onHostQuestion(output.interaction_id); write("interaction_request", { ...record, session_id: clientSessionId }); await runtime.abortSession(sessionId); await runtime.waitForSessionIdle(sessionId, undefined, true); waitingInput = true; done = true; } else if (part.state.status === "error") { emitProgress({ id: part.id, phase: "tool", status: "error", title: getToolProgressTitle(part.tool, part.state.status), detail: part.state.error, }); } continue; } if (part.tool === "present_answer") { if (suppressAnswerUi) continue; if (part.state.status === "completed" && !emittedToolParts.has(part.id)) { emittedToolParts.add(part.id); const value = parseAnswerUiInput(part.state.input); const rejection = value ? answerUiPublication.publish(value) : "回答界面未通过校验,保留文字说明"; if (value && !rejection) { await tokenSmoother.flush(); write("answer_ui", { ...value, id: value.block_id, session_id: clientSessionId }); } else { write("progress", { id: part.id, phase: "visualization", status: "error", title: rejection ?? "回答界面未通过校验,保留文字说明" }); } } await tryCompleteAnswer(); continue; } if (part.tool === "final_answer") { if (part.state.status === "completed") { const output = parseToolOutput(part.state.output); if (output && typeof output === "object" && typeof (output as Record).answer === "string") { finalAnswer = (output as { answer: string }).answer; const refs = (output as { ui_block_ids?: unknown }).ui_block_ids; finalAnswerRefs = refs; await tryCompleteAnswer(); } } continue; } trackBusinessStage(part); if (part.tool === "publish_execution_plan" && part.state.status === "completed" && !emittedToolParts.has(part.id)) { const plan = executionPlanSchema.safeParse(part.state.input); const output = parseToolOutput(part.state.output); const serverPublished = output && typeof output === "object" && typeof (output as Record).plan_revision === "number"; if (plan.success) write("execution_plan", { ...plan.data, session_id: clientSessionId, ...(serverPublished ? { server_published: true } : {}), }); } if (!firstToolEventLogged) { firstToolEventLogged = true; logDevelopmentDebug("first tool event received", { ...debugContext, partId: part.id, tool: part.tool, status: part.state.status, elapsedMs: Math.max(0, Date.now() - requestStartedAt), sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), }); } const toolParams = normalizeToolParams(part.state.input); const reason = extractRequestReason(toolParams); const isToolFinalState = part.state.status === "completed" || part.state.status === "error"; if (previousToolStatus !== nextToolStatus) { logDevelopmentDebug("tool part status changed", { ...debugContext, partId: part.id, tool: part.tool, status: nextToolStatus, reason: reason || null, inputKeys: Object.keys(toolParams).slice(0, 8), error: part.state.status === "error" ? (part.state.error ?? "unknown") : null, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); } const questionToolPayload = normalizeQuestionToolPayload( part, toolParams, clientSessionId, ); if (questionToolPayload) { if (!emittedQuestionToolParts.has(part.id)) { emittedQuestionToolParts.add(part.id); emittedQuestionRequestIds.add(questionToolPayload.request_id); logDevelopmentDebug("question tool request received", { ...debugContext, requestId: questionToolPayload.request_id, tool: part.tool, questionCount: questionToolPayload.questions.length, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: `question-${questionToolPayload.request_id}`, phase: "question", status: "running", title: "等待用户补充信息", detail: questionToolPayload.questions .map((question) => question.question) .join("\n"), }); write("question_request", questionToolPayload); } continue; } emitProgress({ id: part.id, phase: "tool", tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) }, status: normalizeToolStatus(part.state.status), title: getToolProgressTitle(part.tool, part.state.status, part.state.status === "completed" ? part.state.output : undefined), detail: buildToolProgressDetail( part.tool, part.state.status, toolParams, reason, part.state.status === "error" ? part.state.error : undefined, part.state.status === "completed" ? part.state.output : undefined, ), }); if ( !emittedToolParts.has(part.id) && (part.tool === "publish_execution_plan" ? isToolFinalState : (hasToolParams(toolParams) || isToolFinalState)) ) { emittedToolParts.add(part.id); toolCallCount += 1; void writeLlmRequestAuditLog({ kind: "tool", sessionId: sessionId, clientSessionId, traceId, projectId, target: part.tool, reason, reasonProvided: Boolean(reason), payload: toolParams, }).catch((error) => { logger.warn({ err: error }, "failed to write tool audit log"); }); write("tool_call", { session_id: clientSessionId, tool: part.tool, params: toolParams, reason, }); } await tryCompleteAnswer(); } continue; } if (event.type === "todo.updated") { sawResponseActivity = true; const todos = event.properties.todos as Array<{ content: string; status: string; priority: string; }>; const normalizedTodos = todos.map((todo, index) => ({ id: `todo-${index}-${todo.content.slice(0, 24)}`, content: todo.content, status: normalizeTodoStatus(todo.status), priority: normalizeTodoPriority(todo.priority), updated_at: Date.now(), })); const completed = todos.filter( (todo) => todo.status === "completed", ).length; emitProgress({ id: "todo-progress", phase: "planning", status: completed === todos.length ? "completed" : "running", title: `计划进度 ${completed}/${todos.length}`, detail: todos .map((todo) => `${todo.status}: ${todo.content}`) .join("\n"), }); write("todo_update", { session_id: clientSessionId, todos: normalizedTodos, created_at: Date.now(), } satisfies TodoUpdatePayload); continue; } if (event.type === "session.error") { sawResponseActivity = true; logDevelopmentDebug("session error received", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), error: event.properties.error ? getErrorMessage(event.properties.error) : "opencode session error", }); await tokenSmoother.flush(); write("error", { session_id: clientSessionId, message: event.properties.error ? getErrorMessage(event.properties.error) : "opencode session error", detail: event.properties.error?.name, total_duration_ms: Math.max(0, Date.now() - requestStartedAt), }); failed = true; done = true; continue; } if (event.type === "session.idle") { if (!sawResponseActivity) { logDevelopmentDebug("ignoring session idle before response activity", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); continue; } const idleDecision = beforeIdle ? await beforeIdle() : true; if (idleDecision === false || typeof idleDecision === "string") { // A durable message won the race with completion. Continue within // the same managed session/lease, never create another task run. finalAnswer = undefined; sawResponseActivity = false; await runtime.prompt(sessionId, typeof idleDecision === "string" ? idleDecision : "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model)); continue; } if (waitForUserInput && await waitForUserInput()) { // Native inbox admission already resumes queued work; do not inject a // synthetic extra prompt or close the stream between executions. finalAnswer = undefined; sawResponseActivity = false; continue; } logDevelopmentDebug("session idle received", { ...debugContext, emittedText, toolCallCount, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ id: "session-status", phase: "session", status: "completed", title: "Agent 已完成处理", detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。", }); done = true; } } if (aborted) { await tokenSmoother.close({ immediate: true }); logDevelopmentDebug("chat stream aborting session", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); await runtime.abortSession(sessionId).catch((error) => { logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session"); }); await runtime.waitForSessionIdle(sessionId).catch((error) => { logger.warn( { sessionId: sessionId, err: error }, "failed while waiting for aborted opencode session to become idle", ); }); return { aborted: true, failed: false, toolCallCount }; } if (failed) { await tokenSmoother.flush(); return { aborted: false, failed: true, toolCallCount }; } await promptPromise; if (waitingInput) { await tokenSmoother.flush(); emitProgress({ id: "request-completed", phase: "question", status: "completed", title: "等待你的回答", detail: "问题已保存,可以切换会话后继续回答。" }); write("done", { session_id: clientSessionId, reason: "waiting_input" }); return { aborted: false, failed: false, toolCallCount, waitingInput: true }; } if (!emittedText && !finalAnswer) { logDevelopmentDebug("no streamed text emitted, falling back to messages()", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken); } await tokenSmoother.flush(); await publishFinalAnswer(); emitProgress({ id: "request-received", phase: "start", status: "completed", title: "请求处理完成", detail: "本次请求的分析、工具执行和结果整理流程已经完成。", }); emitProgress({ id: "request-completed", phase: "complete", status: "completed", title: "分析完成", detail: (emittedText || finalAnswer) ? "最终回答已生成并推送到前端。" : "已完成分析,并通过兜底消息补发最终回答内容。", }); write("done", { session_id: clientSessionId, total_duration_ms: Math.max(0, Date.now() - requestStartedAt), }); logDevelopmentDebug("chat stream completed", { ...debugContext, emittedText, toolCallCount, totalDurationMs: Math.max(0, Date.now() - requestStartedAt), }); return { aborted: false, failed: false, toolCallCount }; } finally { await tokenSmoother.close({ immediate: true }); await iterator.return?.(); if (!promptSettled && !aborted) { await promptPromise.catch(() => undefined); } else if (!promptSettled) { void promptPromise.catch(() => undefined); } logDevelopmentDebug("chat stream cleanup finished", { ...debugContext, promptSettled, totalDurationMs: Math.max(0, Date.now() - requestStartedAt), }); } };