diff --git a/CHANGELOG.md b/CHANGELOG.md index 5049444..f78b02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ ## Unreleased +## 2.0.17 - 2026-09-21 + +Dashboard keeps failed and interrupted drafts in the conversation, including after refresh. Completed turns keep the thinking-process fold. Interrupted subagents store completed tool calls and visible drafts in the task record for later lookup, without auto-waking the parent. + ## 2.0.16 - 2026-09-18 -Dashboard session search, cheap-model titles, and observational vision/thinking probes. Leftover project agent routes no longer block startup. Settings uses the saved API key for probes and a 64×64 image instead of a 1×1 PNG. Candidate release; GitHub tag pending. +Published 2026-09-18: [v2.0.16](https://github.com/wicm84266964/Ant-Code/releases/tag/v2.0.16). + +Dashboard session search, cheap-model titles, and observational vision/thinking probes. Leftover project agent routes no longer block startup. Settings uses the saved API key for probes and a 64×64 image instead of a 1×1 PNG. ## 2.0.15 - 2026-09-18 diff --git a/docs/releases/2.0.17-interrupt-draft-recovery_zh.md b/docs/releases/2.0.17-interrupt-draft-recovery_zh.md new file mode 100644 index 0000000..a60d427 --- /dev/null +++ b/docs/releases/2.0.17-interrupt-draft-recovery_zh.md @@ -0,0 +1,37 @@ +# Ant Code v2.0.17:失败与中断后保留草稿、思考过程和子任务进展 + +发布日期:2026-09-21 + +本版本是 Dashboard 与子智能体恢复小版本。网关失败或上游断流时,对话区立即显示报错,并把已经流出的可见草稿写入会话;正常完成的一轮会保留「思考过程」折叠条。子智能体被停止或断流时,已完成的工具调用和可见草稿写入任务记录,主控稍后查阅即可捡回。候选阶段不提前声称 CI、tag 或 GitHub Release 已经完成。 + +## 对用户工作流的直接变化 + +- Dashboard 模型请求失败时,错误会出现在对话区,不必整页刷新。 +- 第一个会话如果失败,刷新后仍打开该会话,不再回到「我将全力配合你的工作」空首页。 +- 上游中途断开时,已经贴出的可见草稿留在对话里,并标成「已中断 · 非最终回复」;刷新后仍在。 +- 正常最终回复到达后,「思考过程」折叠条与最终回复一起保留,刷新后两者都还在。 +- 子智能体中断或断流时,不自动唤醒主控;任务记录里保留已完成工具和可见草稿,可用任务详情或 `/agents continue ` 继续。 +- 子智能体发给网关的请求改为流式,便于在断流时截取可见草稿。内部 thinking 正文仍不作为可见汇报展开。 + +## 配置、数据与安全边界 + +- 不改变权限模式、凭据存储格式或网关配置格式。 +- 会话 transcript 会多出中断草稿和思考过程条目;现有 session 可继续打开,无需重建。 +- 子智能体任务记录会写入中断时的 output、toolCalls 和 continuationPrompt。 +- 自动化测试不能代表所有私有研究数据或每一个外部模型供应商都已验证。 + +## 升级与产物位置 + +```sh +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` 应显示 `2.0.17`。正在运行的 Dashboard 需要重启并硬刷新浏览器。现有网关配置和 `.lab-agent` 会话不必重建。 + +## 验证、限制与待完成门禁 + +- 开发仓类型检查 0 诊断。本轮相关单元测试(Dashboard UI、会话失败落盘、子智能体中断保留)已通过。候选阶段的完整 `verify:release`、正式仓 CI、tag 和 GitHub Release 完成后才会记入发布记录。 +- 已知限制:模型内部 thinking 正文默认不在 Dashboard 展开;子智能体中断不自动唤醒主控。最终结论以正式仓同一提交的 CI 和 Release 门禁为准。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3a94ea4..25fd3aa 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.16", + "version": "2.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.16", + "version": "2.0.17", "dependencies": { "@napi-rs/canvas": "1.0.9", "@vscode/ripgrep": "1.18.0", diff --git a/package-lock.json b/package-lock.json index 3a94ea4..25fd3aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.16", + "version": "2.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.16", + "version": "2.0.17", "dependencies": { "@napi-rs/canvas": "1.0.9", "@vscode/ripgrep": "1.18.0", diff --git a/package.json b/package.json index 6ec400c..03c24fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.16", + "version": "2.0.17", "description": "Ant Code local coding agent development package.", "type": "module", "private": true, diff --git a/src/agents/continuation.ts b/src/agents/continuation.ts index e8688b0..0010515 100644 --- a/src/agents/continuation.ts +++ b/src/agents/continuation.ts @@ -79,6 +79,93 @@ export function createPartialSubagentResult(options: { }; } +export function createInterruptedSubagentResult(options: { + profile: { name?: string; mode?: unknown; [key: string]: unknown }; + query: string; + reason?: { code?: string; message?: string; [key: string]: unknown }; + tools?: ContinuationTool[]; + draftText?: unknown; + draftThinkingBytes?: unknown; + model?: string; + mode?: string; + interrupted?: boolean; +}) { + const interrupted = options.interrupted !== false; + const completed = summarizeCompletedTools(options.tools ?? []); + const draftText = String(options.draftText ?? "").trim(); + const thinkingBytes = Number(options.draftThinkingBytes); + const reason = options.reason && typeof options.reason === "object" + ? options.reason + : { code: "AGENT_INTERRUPTED", message: "Subagent was interrupted." }; + const message = String(reason.message ?? "Subagent was interrupted."); + const continuationPrompt = [ + `继续这个 ${options.profile.name} 子任务。`, + "", + "原始任务:", + options.query, + "", + "中断原因:", + message, + "", + "已完成摘要:", + completed.length ? completed.map((item) => `- ${item}`).join("\n") : "- 尚无成功工具结果。", + "", + draftText ? "中断时已有可见草稿,请在此基础上继续,不要从头重做。" : "中断时没有可见正文草稿。", + "", + "请从 remaining 工作继续,避免重复已完成步骤,并返回结构化阶段结果。" + ].join("\n"); + const output = formatInterruptedOutput({ + message, + completed, + draftText, + thinkingBytes: Number.isFinite(thinkingBytes) && thinkingBytes > 0 ? thinkingBytes : 0 + }); + return { + ok: false, + interrupted, + status: interrupted ? "interrupted" : "failed", + profile: options.profile.name, + mode: options.mode ?? options.profile.mode, + modelDriven: true, + model: options.model, + query: options.query, + output, + outputFull: output, + outputTruncated: output.length > MAX_PARTIAL_OUTPUT, + continuationPrompt, + tools: options.tools ?? [], + error: { + code: String(reason.code ?? "AGENT_INTERRUPTED"), + message + } + }; +} + +function formatInterruptedOutput(options: { + message: string; + completed: string[]; + draftText: string; + thinkingBytes: number; +}) { + const text = [ + "子智能体已中断", + "", + `原因:${options.message}`, + "", + "已完成工具:", + ...(options.completed.length ? options.completed.map((item) => `- ${item}`) : ["- 尚无成功工具结果。"]), + "", + options.draftText + ? `中断草稿(非最终回复):\n${options.draftText}` + : options.thinkingBytes > 0 + ? `中断草稿:无可见正文。内部思考已产生 ${options.thinkingBytes} 字节,未作为可见正文保留。` + : "中断草稿:无可见正文。", + "", + "主控稍后读取该任务记录即可捡回上述内容。可用 /agents continue 继续。" + ].join("\n"); + return text.length <= MAX_PARTIAL_OUTPUT ? text : `${text.slice(0, MAX_PARTIAL_OUTPUT)}\n...[interrupted output truncated]`; +} + function summarizeCompletedTools(tools: ContinuationTool[] = []) { return tools .filter((tool) => tool.ok === true) diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 4f41505..f9f2735 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -16,7 +16,7 @@ import { suggestValidationCommands } from "../core/validation-suggestions.ts"; import { accumulateProviderUsage } from "../core/provider-usage.ts"; import { createBudgetTracker, checkBudget, recordBudgetToolResult, resolveAgentBudget, resolveAgentModel } from "./budget.ts"; import { buildContextPack, formatContextPack, hasWriteScope } from "./context-pack.ts"; -import { createPartialSubagentResult } from "./continuation.ts"; +import { createInterruptedSubagentResult, createPartialSubagentResult } from "./continuation.ts"; import { formatOutputContract, summarizeContractResult } from "./contracts.ts"; import { createPlanPackageStore, extractPlanPackage } from "./plan-package-store.ts"; import { getAgentProfile, listAgentProfileLabels, type AgentProfile } from "./profiles.ts"; @@ -335,7 +335,7 @@ export async function runSubagent(options: { finishedAt: new Date().toISOString(), latestProgress: persistedPlanPackage?.ok ? `子智能体已完成;计划包已保存到 ${persistedPlanPackage.path}` - : result.partial ? "子智能体阶段性暂停,可继续" : result.ok ? "子智能体已完成" : (isPlainObject(result.error) ? String(result.error.message ?? "子智能体失败") : "子智能体失败"), + : result.partial ? "子智能体阶段性暂停,可继续" : result.ok ? "子智能体已完成" : result.interrupted ? "子智能体已中断,已保留工具记录和草稿" : (isPlainObject(result.error) ? String(result.error.message ?? "子智能体失败") : "子智能体失败"), toolCalls: result.tools ?? [], outputSummary: summarizeAgentOutput(result), output: persistedOutput?.preview ?? result.output ?? "", @@ -513,6 +513,11 @@ async function runModelSubagent(options: { let finalReportRequested = false; let lastPromptProgress: Record = {}; let providerUsageProgress: Record = {}; + const draftCapture = { + text: "", + thinking: "", + thinkingBytes: 0 + }; try { for (let round = 0; ; round += 1) { @@ -541,12 +546,10 @@ async function runModelSubagent(options: { }); } if (options.signal?.aborted) { - return { - ok: false, - profile: options.profile.name, - interrupted: true, - error: { code: "AGENT_INTERRUPTED", message: "Subagent was interrupted." } - }; + return haltedSubagentResult(options, toolExecutions, draftCapture, { + code: "AGENT_INTERRUPTED", + message: "Subagent was interrupted." + }, true); } const promptEstimate = estimatePromptPayload({ @@ -567,20 +570,30 @@ async function runModelSubagent(options: { }) }); + draftCapture.text = ""; + draftCapture.thinking = ""; + draftCapture.thinkingBytes = 0; const response = await options.gateway.sendChat({ messages, tools: finalReportRequested ? [] : toolDefinitions, toolResults, sessionId, - stream: false, - signal: options.signal + stream: true, + signal: options.signal, + onEvent: (event) => captureSubagentDraftEvent(draftCapture, event) }); if (!response.ok) { + const interrupted = options.signal?.aborted === true || isSubagentStreamHalt(response.error); await options.taskStore?.updateTask(options.taskId, { - latestProgress: response.error?.message ?? "模型请求失败", + latestProgress: interrupted + ? "子智能体已中断,已保留工具记录和草稿" + : (response.error?.message ?? "模型请求失败"), error: response.error ?? null }); + if (interrupted || toolExecutions.length > 0 || draftCapture.text.trim() || draftCapture.thinkingBytes > 0) { + return haltedSubagentResult(options, toolExecutions, draftCapture, response.error, interrupted); + } return { ok: false, profile: options.profile.name, @@ -792,14 +805,10 @@ async function runModelSubagent(options: { }); } if (options.signal?.aborted) { - return { - ok: false, - profile: options.profile.name, - mode: options.profile.mode, - interrupted: true, - error: { code: "AGENT_INTERRUPTED", message: "Subagent was interrupted." }, - tools: toolExecutions - }; + return haltedSubagentResult(options, toolExecutions, draftCapture, { + code: "AGENT_INTERRUPTED", + message: "Subagent was interrupted." + }, true); } if (finalReportReason && !finalReportRequested && shouldRequestFinalReportAfterBudgetHit(options.profile, finalReportReason)) { finalReportRequested = true; @@ -970,7 +979,7 @@ function summarizeAgentOutput(result: Record | SubagentResult) if (!result) { return ""; } - if (result.ok && result.output) { + if (result.output) { return truncateText(String(result.output)).split(/\r?\n/).slice(0, 8).join("\n"); } if (result.summary) { @@ -980,6 +989,53 @@ function summarizeAgentOutput(result: Record | SubagentResult) return asString(error?.message) ?? ""; } +function haltedSubagentResult( + options: { + profile: AgentProfile; + query: string; + config: LabAgentConfig; + }, + tools: Array>, + draft: { text: string; thinkingBytes: number }, + error: unknown, + interrupted: boolean +) { + const record = isPlainObject(error) ? error : EMPTY_RECORD; + return createInterruptedSubagentResult({ + profile: options.profile, + query: options.query, + reason: { + code: String(record.code ?? (interrupted ? "AGENT_INTERRUPTED" : "AGENT_HALTED")), + message: String(record.message ?? (interrupted ? "Subagent was interrupted." : "Subagent stopped before a final report.")) + }, + tools, + draftText: draft.text, + draftThinkingBytes: draft.thinkingBytes, + model: options.config.modelAlias, + mode: asString(options.profile.mode), + interrupted + }); +} + +function captureSubagentDraftEvent(capture: { text: string; thinking: string; thinkingBytes: number }, event: Record) { + if (event.type === "text_delta" && typeof event.text === "string" && event.text) { + capture.text += event.text; + return; + } + if (event.type === "thinking_delta" && typeof event.text === "string" && event.text) { + capture.thinking += event.text; + capture.thinkingBytes += Number(event.bytes ?? Buffer.byteLength(event.text, "utf8")); + } +} + +function isSubagentStreamHalt(error: unknown) { + const code = String(isPlainObject(error) ? error.code ?? "" : ""); + return code === "AGENT_INTERRUPTED" + || code === "GATEWAY_STREAM_INTERRUPTED" + || code === "UPSTREAM_STREAM_ABORTED" + || code === "GATEWAY_RESPONSE_PARSE_ERROR"; +} + async function emitSubagentHook( options: { cwd: string; env?: NodeJS.ProcessEnv; hooksTrusted?: boolean; parentSessionId?: string }, config: unknown, @@ -1421,13 +1477,18 @@ function formatSubagentOutput(value: unknown) { } async function persistResultOutput(taskStore: ReturnType | undefined, taskId: string, result: SubagentResult) { - if (!result || typeof result.outputFull !== "string" || result.outputFull.length === 0) { + const full = typeof result.outputFull === "string" && result.outputFull + ? result.outputFull + : (result.interrupted === true || result.partial === true) && typeof result.output === "string" + ? result.output + : ""; + if (!full) { return null; } if (typeof taskStore?.writeTaskOutput !== "function") { return null; } - return taskStore.writeTaskOutput(taskId, result.outputFull); + return taskStore.writeTaskOutput(taskId, full); } async function persistPlannerPlanPackage(options: { diff --git a/src/core/session-messages.ts b/src/core/session-messages.ts index c3abc3e..b047ff6 100644 --- a/src/core/session-messages.ts +++ b/src/core/session-messages.ts @@ -453,6 +453,9 @@ export function messagesForModelContext(messages: unknown = []): SessionMessage[ if (!message || typeof message !== "object") { return []; } + if ("thinkingProcess" in message && message.thinkingProcess === true) { + return []; + } const { interruptedDraft: _interruptedDraft, attachments: _attachments, ...rest } = message; return [rest]; }); diff --git a/src/core/session-persist.ts b/src/core/session-persist.ts index 6f2ac42..b3fb0e0 100644 --- a/src/core/session-persist.ts +++ b/src/core/session-persist.ts @@ -175,6 +175,7 @@ export function normalizeClientSurfaceValue(value: unknown) { */ export async function appendSessionMessages(session: AgentSession, data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse, fallbackText: string, options: { thinking?: unknown; + thinkingProcess?: unknown; turnMessages?: SessionMessage[]; transcriptMessages?: SessionMessage[]; gateway?: ReturnType; @@ -206,8 +207,16 @@ export async function appendSessionMessages(session: AgentSession, data: import( const turnMessages = Array.isArray(options.turnMessages) ? options.turnMessages : []; const transcriptMessages = Array.isArray(options.transcriptMessages) ? options.transcriptMessages : turnMessages; + const thinkingProcessText = String(options.thinkingProcess ?? "").trim(); + const thinkingProcessMessage: SessionMessage[] = thinkingProcessText + ? [{ + role: "assistant", + thinkingProcess: true, + content: [{ type: "text", text: thinkingProcessText }] + }] + : []; session.messages.push(...turnMessages, contextAssistant); - appendTranscriptMessages(session, [...transcriptMessages, transcriptAssistant]); + appendTranscriptMessages(session, [...transcriptMessages, ...thinkingProcessMessage, transcriptAssistant]); appendModelContextArchiveMessages(session, [...turnMessages, contextAssistant]); return compactSessionContextWithModel(session, { @@ -238,6 +247,63 @@ export async function appendSessionMessages(session: AgentSession, data: import( */ +const VISIBLE_FAILED_TURN_STATUSES = new Set([ + "blocked", + "context_overflow", + "error", + "failed", + "gateway_error", + "gateway_not_configured", + "tool_limit", + "unexpected_loop_end", + "vision_error", + "vision_unavailable" +]); + +export function ensureVisibleFailedTurnTranscript(session: AgentSession, options: { prompt?: unknown; output?: unknown } = {}) { + const userText = String(options.prompt ?? "").trim(); + const errorText = String(options.output ?? "").trim(); + if (!userText && !errorText) { + return; + } + const messages = Array.isArray(session.transcriptMessages) ? session.transcriptMessages : []; + const lastUser = [...messages].reverse().find((message) => message?.role === "user"); + const lastText = transcriptMessageText(messages.at(-1)); + const toAdd: SessionMessage[] = []; + if (userText && transcriptMessageText(lastUser) !== userText) { + toAdd.push({ role: "user", content: userText }); + } + const trailingText = toAdd.length > 0 ? transcriptMessageText(toAdd.at(-1)) : lastText; + if (errorText && trailingText !== errorText) { + toAdd.push({ role: "assistant", content: errorText }); + } + if (toAdd.length > 0) { + appendTranscriptMessages(session, toAdd); + } +} + +function transcriptMessageText(message: SessionMessage | undefined) { + if (!message) { + return ""; + } + const content = message.content; + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + return content.map((item) => { + if (typeof item === "string") { + return item; + } + if (item && typeof item === "object" && "text" in item) { + return String(item.text ?? ""); + } + return ""; + }).filter(Boolean).join("\n").trim(); +} + /** * @param {ReturnType} store * @param {Record} metadata @@ -247,6 +313,12 @@ export async function appendSessionMessages(session: AgentSession, data: import( */ export async function persistSessionMetadata(store: ReturnType, metadata: Record, output: string, status: string, session: AgentSession, options: Record = {}) { metadata.status = status; + if (VISIBLE_FAILED_TURN_STATUSES.has(String(status ?? "").trim().toLowerCase())) { + ensureVisibleFailedTurnTranscript(session, { + prompt: metadata.prompt, + output + }); + } metadata.finishedAt = new Date().toISOString(); metadata.outputBytes = Buffer.byteLength(output, "utf8"); const usage = normalizeProviderUsageAggregate(session.usage); @@ -707,6 +779,9 @@ export function persistableMessage(message: unknown, options: Record line !== "").join("\n"); const thinking = normalizeAssistantThinking({ text: draft.thinking, bytes: draft.thinkingBytes, source: "gateway-interrupted" }); + const transcriptAssistant: SessionMessage = { + role: "assistant", + content: [{ type: "text", text: transcriptNote }], + interruptedDraft: true + }; + const contextAssistant: SessionMessage = { + role: "assistant", + content: visibleText ? [{ type: "text", text: transcriptNote }] : [], + interruptedDraft: true + }; if (thinking) { - assistantMessage.thinking = thinking; + transcriptAssistant.thinking = thinking; + contextAssistant.thinking = thinking; } if (typeof prompt === "string" && prompt.trim()) { const userMessage = persistableUserTurnMessage(prompt); session.messages.push(userMessage); appendModelContextArchiveMessages(session, [userMessage]); } - session.messages.push(assistantMessage); - appendModelContextArchiveMessages(session, [assistantMessage]); + session.messages.push(contextAssistant); + appendModelContextArchiveMessages(session, [contextAssistant]); appendTranscriptMessages(session, [ ...(typeof displayPrompt === "string" && displayPrompt.trim() ? [{ role: "user", content: displayPrompt }] : []), - assistantMessage + transcriptAssistant ]); } diff --git a/src/core/session-turn.ts b/src/core/session-turn.ts index e03a152..8abd3e4 100644 --- a/src/core/session-turn.ts +++ b/src/core/session-turn.ts @@ -606,6 +606,7 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT hooksTrusted: options.hooksTrusted, eventOptions, thinking, + thinkingProcess: interruptedDraft.text, turnMessages, transcriptMessages: transcriptTurnMessages }); diff --git a/src/core/session-types.ts b/src/core/session-types.ts index 731b25e..cb77178 100644 --- a/src/core/session-types.ts +++ b/src/core/session-types.ts @@ -75,6 +75,7 @@ export type SessionMessage = { toolCalls?: Array<{ id?: string; name?: string; input?: unknown }>; toolCallId?: string; interruptedDraft?: boolean; + thinkingProcess?: boolean; attachments?: SessionAttachmentChip[]; }; diff --git a/src/dashboard/public/app-ui2.ts b/src/dashboard/public/app-ui2.ts index cede25a..28580c3 100644 --- a/src/dashboard/public/app-ui2.ts +++ b/src/dashboard/public/app-ui2.ts @@ -187,10 +187,11 @@ export async function restoreInitialSession() { return; } const sessionId = initialSessionId() || latestBackgroundSessionId(); - if (!sessionId || !state.sessions.some((session) => session.id === sessionId)) { + if (!sessionId) { return; } - await openSession(sessionId); + const listed = state.sessions.some((session) => session.id === sessionId); + await openSession(sessionId, { restore: !listed }); } export function latestBackgroundSessionId() { @@ -400,7 +401,7 @@ export function handleSessionAction(action: unknown, sessionId: string) { } } -export async function openSession(id: string) { +export async function openSession(id: string, options: { restore?: boolean } = {}) { state.turnRequest = null; const request = beginScopedRequest("session", id); cancelScopedRequest("transcript"); @@ -410,7 +411,7 @@ export async function openSession(id: string) { try { result = await getJson(`/api/sessions/${encodeURIComponent(id)}`, { signal: request.signal }); } catch (error) { - if (!isAbortError(error) && isCurrentScopedRequest(request)) { + if (!isAbortError(error) && isCurrentScopedRequest(request) && options.restore !== true) { showError(errorMessageOf(error) || "无法读取会话"); } finishScopedRequest(request); @@ -419,12 +420,16 @@ export async function openSession(id: string) { if (!isCurrentScopedRequest(request)) return; finishScopedRequest(request); if (!result.ok) { - showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); + if (options.restore !== true) { + showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); + } return; } const loadedSession = result.session; if (!loadedSession) { - showError("无法读取会话"); + if (options.restore !== true) { + showError("无法读取会话"); + } return; } state.currentSessionId = id; diff --git a/src/dashboard/public/app-ui3.ts b/src/dashboard/public/app-ui3.ts index 1ab79a2..79d3c31 100644 --- a/src/dashboard/public/app-ui3.ts +++ b/src/dashboard/public/app-ui3.ts @@ -3,7 +3,7 @@ import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; -import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, clearPermissionWaitActivity, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, applySessionFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, userTranscriptDisplayText, transcriptMessageAttachments, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; +import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, keepInterruptedAssistantDrafts, createInterruptedDraftNode, isInterruptedDraftMessage, isThinkingProcessMessage, createThinkingProcessNode, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, clearPermissionWaitActivity, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, applySessionFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, userTranscriptDisplayText, transcriptMessageAttachments, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export async function cancelBackgroundSubagent(groupId: unknown, taskId: unknown) { const key = backgroundCancelKey(groupId, taskId); if (!state.currentSessionId || !key || state.backgroundCancelling.has(key)) { @@ -370,6 +370,8 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { els.runStatus.textContent = "引导中"; setLiveTitle("正在按引导继续"); } else if (!state.running) { + collapseAssistantDrafts(); + collapseCompletedActivities(); clearPendingGuide(); resetLiveStatus({ keepBackgroundSubagents: true }); applyIdleRunStatus("空闲"); @@ -482,7 +484,7 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { } if (event.type === "activity") { if (event.rawType === "turn_interrupted") { - collapseAssistantDrafts(); + keepInterruptedAssistantDrafts(); } if (isBackgroundSubagentActivity(event)) { handleBackgroundSubagentActivity(event); @@ -558,6 +560,10 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { } return; } + if (event.type === "assistant_interrupted_draft") { + keepInterruptedAssistantDrafts(); + return; + } if (event.type === "assistant_final") { beginEventTurn(event); const finalSignature = normalizeComparableText(event.text); @@ -605,6 +611,8 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { } resetLiveStatus({ keepBackgroundSubagents: true }); clearPendingGuide(); + keepInterruptedAssistantDrafts(); + collapseCompletedActivities(); showError(event.message ?? "任务失败"); applyIdleRunStatus("失败"); updateSendButton(); @@ -642,6 +650,18 @@ export function renderTranscriptMessages(messages: unknown, options: Record + + 思考过程 + ${body ? "已收起" : "已收起 · 已汇入最终回复"} + +
+ `; + const list = node.querySelector(".draft-summary-list"); + if (body && list) { + const item = document.createElement("section"); + item.className = "draft-summary-item"; + const title = document.createElement("div"); + title.className = "draft-summary-title"; + title.textContent = "草稿"; + const bodyNode = document.createElement("div"); + bodyNode.className = "message-body draft-plain-text"; + bodyNode.textContent = body; + item.append(title, bodyNode); + list.append(item); + } else if (list) { + const note = document.createElement("div"); + note.className = "draft-summary-note"; + note.textContent = "本轮流式草稿已合并到最终回复,没有额外过程内容。"; + list.append(note); + } + return node; +} + +export function isInterruptedDraftMessage(message: unknown) { + if (!isPlainObject(message) || visibleTranscriptRole(String(message.role ?? "")) !== "assistant") { + return false; + } + if (message.interruptedDraft === true) { + return true; + } + return /^\[中断草稿,非最终回复\]/.test(messageDisplayText(message.content)); +} + +export function interruptedDraftDisplayText(content: unknown) { + return String(messageDisplayText(content) ?? "") + .replace(/^\[中断草稿,非最终回复\]\s*/u, "") + .replace(/^原因:[^\n]*\s*/u, "") + .replace(/^本轮在给出可见正文前被上游断开。\s*/u, "") + .trim(); +} + +export function createInterruptedDraftNode(message: unknown) { + const record = isPlainObject(message) ? message : {}; + const body = interruptedDraftDisplayText(record.content); + const node = document.createElement("article"); + node.className = "message assistant draft-message interrupted"; + node.setAttribute("aria-live", "off"); + node.innerHTML = ` +
思考 · 已中断 · 非最终回复
+
+ `; + const bodyNode = node.querySelector(".message-body"); + if (bodyNode) { + bodyNode.textContent = body || "本轮在给出可见正文前被上游断开。模型内部思考没有作为最终回复展示。"; + } + return node; +} + export function isMeaningfulCompletedActivity(activity: DashboardActivity) { if (activity.toolName === "agent_run") { return true; diff --git a/src/dashboard/public/app.js b/src/dashboard/public/app.js index 416d397..22dad4f 100644 --- a/src/dashboard/public/app.js +++ b/src/dashboard/public/app.js @@ -558,10 +558,11 @@ async function restoreInitialSession() { return; } const sessionId = initialSessionId() || latestBackgroundSessionId(); - if (!sessionId || !state.sessions.some((session) => session.id === sessionId)) { + if (!sessionId) { return; } - await openSession(sessionId); + const listed = state.sessions.some((session) => session.id === sessionId); + await openSession(sessionId, { restore: !listed }); } function latestBackgroundSessionId() { return state.sessions.find((session) => session.backgroundVisible === true)?.id ?? ""; @@ -750,7 +751,7 @@ function handleSessionAction(action, sessionId) { copySessionId(sessionId); } } -async function openSession(id) { +async function openSession(id, options = {}) { state.turnRequest = null; const request = beginScopedRequest("session", id); cancelScopedRequest("transcript"); @@ -760,7 +761,7 @@ async function openSession(id) { try { result = await getJson(`/api/sessions/${encodeURIComponent(id)}`, { signal: request.signal }); } catch (error) { - if (!isAbortError(error) && isCurrentScopedRequest(request)) { + if (!isAbortError(error) && isCurrentScopedRequest(request) && options.restore !== true) { showError(errorMessageOf(error) || "无法读取会话"); } finishScopedRequest(request); @@ -769,12 +770,16 @@ async function openSession(id) { if (!isCurrentScopedRequest(request)) return; finishScopedRequest(request); if (!result.ok) { - showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); + if (options.restore !== true) { + showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); + } return; } const loadedSession = result.session; if (!loadedSession) { - showError("无法读取会话"); + if (options.restore !== true) { + showError("无法读取会话"); + } return; } state.currentSessionId = id; @@ -2616,6 +2621,8 @@ function handleDashboardEvent3(event) { els.runStatus.textContent = "引导中"; setLiveTitle("正在按引导继续"); } else if (!state.running) { + collapseAssistantDrafts3(); + collapseCompletedActivities3(); clearPendingGuide(); resetLiveStatus({ keepBackgroundSubagents: true }); applyIdleRunStatus("空闲"); @@ -2728,7 +2735,7 @@ function handleDashboardEvent3(event) { } if (event.type === "activity") { if (event.rawType === "turn_interrupted") { - collapseAssistantDrafts3(); + keepInterruptedAssistantDrafts(); } if (isBackgroundSubagentActivity3(event)) { handleBackgroundSubagentActivity3(event); @@ -2804,6 +2811,10 @@ function handleDashboardEvent3(event) { } return; } + if (event.type === "assistant_interrupted_draft") { + keepInterruptedAssistantDrafts(); + return; + } if (event.type === "assistant_final") { beginEventTurn3(event); const finalSignature = normalizeComparableText3(event.text); @@ -2851,6 +2862,8 @@ function handleDashboardEvent3(event) { } resetLiveStatus({ keepBackgroundSubagents: true }); clearPendingGuide(); + keepInterruptedAssistantDrafts(); + collapseCompletedActivities3(); showError(event.message ?? "任务失败"); applyIdleRunStatus("失败"); updateSendButton(); @@ -2885,6 +2898,18 @@ function renderTranscriptMessages(messages, options = {}) { const nodes = []; const list = Array.isArray(messages) ? messages : []; for (const message of list) { + if (isThinkingProcessMessage(message)) { + const node2 = createThinkingProcessNode(message); + node2.setAttribute("aria-live", "off"); + nodes.push(node2); + continue; + } + if (isInterruptedDraftMessage(message)) { + const node2 = createInterruptedDraftNode(message); + node2.setAttribute("aria-live", "off"); + nodes.push(node2); + continue; + } const role = visibleTranscriptRole(isPlainObject(message) ? message.role : message); if (!role) { continue; @@ -3389,9 +3414,21 @@ function handleActivity3(activity) { updateLiveActivity4(activity); return; } - const shouldKeep = activity.status === "failed" || activity.status === "blocked" || isMeaningfulCompletedActivity4(activity); removeLiveActivity4(activity); - if (shouldKeep) { + if (activity.status === "failed") { + keepInterruptedAssistantDrafts(); + } + if (activity.status === "failed" || activity.status === "blocked") { + appendActivity({ + ...activity, + title: String(activity.title ?? (activity.status === "failed" ? "发生错误" : "任务未完成")), + detail: String(activity.detail ?? ""), + severity: activity.status === "failed" ? "danger" : "warning", + collapsed: false + }); + return; + } + if (isMeaningfulCompletedActivity4(activity)) { state.completedActivities.push(activity); } } @@ -6984,6 +7021,90 @@ function collapseAssistantDrafts3(finalText = "") { appendTranscriptNode3(node); scrollTranscript({ onlyIfNearBottom: true }); } +function keepInterruptedAssistantDrafts() { + for (const draft of state.assistantDrafts.values()) { + cancelScheduledAnimationFrame2(draft, "renderFrame"); + renderAssistantDraft4(draft, { force: true }); + const node = draft.node; + if (node instanceof HTMLElement) { + markInterruptedDraftNode(node, Number.isFinite(draft.round) ? Number(draft.round) : null); + } + } + state.assistantDrafts.clear(); +} +function markInterruptedDraftNode(node, round = null) { + node.classList.add("interrupted"); + const label = node.querySelector(".message-label"); + if (label) { + const prefix = Number.isInteger(round) ? `思考 · 第 ${round} 轮` : "思考"; + label.textContent = `${prefix} · 已中断 · 非最终回复`; + } +} +function isThinkingProcessMessage(message) { + return isPlainObject(message) && message.thinkingProcess === true && visibleTranscriptRole(String(message.role ?? "")) === "assistant"; +} +function createThinkingProcessNode(message) { + const record = isPlainObject(message) ? message : {}; + const body = String(messageDisplayText3(record.content) ?? "").trim(); + const node = document.createElement("details"); + node.className = "draft-summary"; + node.setAttribute("aria-live", "off"); + node.innerHTML = ` + + + 思考过程 + ${body ? "已收起" : "已收起 · 已汇入最终回复"} + +
+ `; + const list = node.querySelector(".draft-summary-list"); + if (body && list) { + const item = document.createElement("section"); + item.className = "draft-summary-item"; + const title = document.createElement("div"); + title.className = "draft-summary-title"; + title.textContent = "草稿"; + const bodyNode = document.createElement("div"); + bodyNode.className = "message-body draft-plain-text"; + bodyNode.textContent = body; + item.append(title, bodyNode); + list.append(item); + } else if (list) { + const note = document.createElement("div"); + note.className = "draft-summary-note"; + note.textContent = "本轮流式草稿已合并到最终回复,没有额外过程内容。"; + list.append(note); + } + return node; +} +function isInterruptedDraftMessage(message) { + if (!isPlainObject(message) || visibleTranscriptRole(String(message.role ?? "")) !== "assistant") { + return false; + } + if (message.interruptedDraft === true) { + return true; + } + return /^\[中断草稿,非最终回复\]/.test(messageDisplayText3(message.content)); +} +function interruptedDraftDisplayText(content) { + return String(messageDisplayText3(content) ?? "").replace(/^\[中断草稿,非最终回复\]\s*/u, "").replace(/^原因:[^\n]*\s*/u, "").replace(/^本轮在给出可见正文前被上游断开。\s*/u, "").trim(); +} +function createInterruptedDraftNode(message) { + const record = isPlainObject(message) ? message : {}; + const body = interruptedDraftDisplayText(record.content); + const node = document.createElement("article"); + node.className = "message assistant draft-message interrupted"; + node.setAttribute("aria-live", "off"); + node.innerHTML = ` +
思考 · 已中断 · 非最终回复
+
+ `; + const bodyNode = node.querySelector(".message-body"); + if (bodyNode) { + bodyNode.textContent = body || "本轮在给出可见正文前被上游断开。模型内部思考没有作为最终回复展示。"; + } + return node; +} function isMeaningfulCompletedActivity4(activity) { if (activity.toolName === "agent_run") { return true; @@ -10235,7 +10356,9 @@ export { contextSummaryLine3 as contextSummaryLine, copyCodeBlock10 as copyCodeBlock, copySessionId, + createInterruptedDraftNode, createMessageNode3 as createMessageNode, + createThinkingProcessNode, currentGatewayCatalogModels6 as currentGatewayCatalogModels, currentGatewayProbeResult6 as currentGatewayProbeResult, currentGatewayProfile5 as currentGatewayProfile, @@ -10352,6 +10475,7 @@ export { initializeAgentModelPickerSnapshot5 as initializeAgentModelPickerSnapshot, initializeSettingsFormTracking5 as initializeSettingsFormTracking, interruptTurn, + interruptedDraftDisplayText, isAbortError, isBackgroundSubagentActivity3 as isBackgroundSubagentActivity, isConfigRevisionConflict7 as isConfigRevisionConflict, @@ -10361,13 +10485,16 @@ export { isDuplicateDraftText8 as isDuplicateDraftText, isExhibitFile, isInterruptError3 as isInterruptError, + isInterruptedDraftMessage, isLikelyLocalFileReference10 as isLikelyLocalFileReference, isMeaningfulCompletedActivity4 as isMeaningfulCompletedActivity, isPlainObject, isProtectedTranscriptNode3 as isProtectedTranscriptNode, isSafeInlineBitmapUrl10 as isSafeInlineBitmapUrl, + isThinkingProcessMessage, isTranscriptNearBottom3 as isTranscriptNearBottom, isWorkspaceRelativeToBase11 as isWorkspaceRelativeToBase, + keepInterruptedAssistantDrafts, latestBackgroundSessionId, lifecycleActivitySummary8 as lifecycleActivitySummary, linkifyFileTextNodes9 as linkifyFileTextNodes, @@ -10382,6 +10509,7 @@ export { manualAgentModelIds6 as manualAgentModelIds, markCurrentModel, markEventConnectionAlive3 as markEventConnectionAlive, + markInterruptedDraftNode, markModelConfigCredentialChanged5 as markModelConfigCredentialChanged, markModelConfigEndpointChanged5 as markModelConfigEndpointChanged, markReasoningCapabilityManual5 as markReasoningCapabilityManual, diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index 4054664..e6e778e 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -5,7 +5,7 @@ Ant Code Dashboard - + @@ -229,6 +229,6 @@

设置

- + diff --git a/src/dashboard/public/styles.css b/src/dashboard/public/styles.css index a68409f..cf73b2f 100644 --- a/src/dashboard/public/styles.css +++ b/src/dashboard/public/styles.css @@ -1158,6 +1158,15 @@ button { color: var(--accent-2); } +.message.draft-message.interrupted { + border-color: rgba(232, 90, 90, 0.35); + background: rgba(232, 90, 90, 0.06); +} + +.message.draft-message.interrupted .message-label { + color: #e07a7a; +} + .draft-plain-text { contain: content; white-space: pre-wrap; diff --git a/src/dashboard/runtime/api-sessions.ts b/src/dashboard/runtime/api-sessions.ts index 1a39cc6..9e8cfe8 100644 --- a/src/dashboard/runtime/api-sessions.ts +++ b/src/dashboard/runtime/api-sessions.ts @@ -129,6 +129,7 @@ import { deleteDashboardSession, hasTranscriptCursor, mergeActiveTranscriptPage, + includeInterruptedDraftsFromEvents, persistedSessionFailure, publicBackgroundSnapshot, readStoredTranscriptPage, @@ -248,7 +249,7 @@ export async function runtimeReadSession(ctx: DashboardFactoryState, selector: u const transcriptPage = activeState ? mergeActiveTranscriptPage(storedPage, activeState) : storedPage; - const transcript = transcriptPage.messages; + const transcript = includeInterruptedDraftsFromEvents(transcriptPage.messages, activeState); const finalText = activeState?.finalOutput || assistantTranscriptText(transcript); const snapshotState = activeState ?? createSnapshotReadState(metadata, ctx.cwd); const backgroundSnapshot = snapshotState ? await buildBackgroundSubagentSnapshot(snapshotState) : null; diff --git a/src/dashboard/runtime/session-records.ts b/src/dashboard/runtime/session-records.ts index b5aa547..5d0b2e2 100644 --- a/src/dashboard/runtime/session-records.ts +++ b/src/dashboard/runtime/session-records.ts @@ -314,6 +314,43 @@ export function transcriptPageReadError(result: unknown) { } +export function includeInterruptedDraftsFromEvents(messages: unknown, state: DashboardActiveSessionState | null | undefined) { + const list = Array.isArray(messages) ? messages.slice() : []; + if (!state || state.running === true) { + return list; + } + if (list.some((message) => isInterruptedDraftTranscriptMessage(message) || isThinkingProcessTranscriptMessage(message))) { + return list; + } + const text = (state.events ?? []) + .filter((event) => event.type === "assistant_draft") + .map((event) => String(event.text ?? "")) + .join(""); + if (!text.trim()) { + return list; + } + list.push({ + role: "assistant", + interruptedDraft: true, + content: [{ type: "text", text: `[中断草稿,非最终回复]\n${text}` }] + }); + return list; +} + +export function isThinkingProcessTranscriptMessage(message: unknown) { + return isPlainObject(message) && message.thinkingProcess === true && String(message.role ?? "") === "assistant"; +} + +export function isInterruptedDraftTranscriptMessage(message: unknown) { + if (!isPlainObject(message) || String(message.role ?? "") !== "assistant") { + return false; + } + if (message.interruptedDraft === true) { + return true; + } + return /^\[中断草稿,非最终回复\]/.test(messageContentText(message.content)); +} + export function mergeActiveTranscriptPage(storedPage: TranscriptPageView, state: DashboardActiveSessionState, options: Record = {}) { const storedMessages = Array.isArray(storedPage.messages) ? storedPage.messages : []; const storedPositions = Array.isArray(storedPage.positions) ? storedPage.positions : []; @@ -547,30 +584,43 @@ export function messageContentText(content: unknown) { /** @param {Record} metadata */ export function persistedSessionFailure(metadata: Record) { const status = String(metadata?.status ?? "").trim().toLowerCase(); - if (!["failed", "error", "gateway_error"].includes(status)) { + if (!["blocked", "context_overflow", "error", "failed", "gateway_error", "gateway_not_configured", "tool_limit", "unexpected_loop_end", "vision_error", "vision_unavailable"].includes(status)) { return null; } const rounds = Array.isArray(metadata?.gatewayRounds) ? metadata.gatewayRounds : []; const round = [...rounds].reverse().find((item) => isPlainObject(item) && isPlainObject(item.error)); const error = isPlainObject(round) && isPlainObject(round.error) ? round.error : null; - if (!error) { - return null; - } - const details = isPlainObject(error.details) ? error.details : null; - const httpStatus = Number.isInteger(error.status) && Number(error.status) > 0 ? Number(error.status) : null; - const attempts = Number.isInteger(details?.attempts) && Number(details?.attempts) > 0 - ? Number(details?.attempts) - : null; + const details = isPlainObject(error?.details) ? error.details : null; + const statusCode = Number(error?.status); + const httpStatus = Number.isInteger(statusCode) && statusCode > 0 ? statusCode : null; + const attemptCount = Number(details?.attempts); + const attempts = Number.isInteger(attemptCount) && attemptCount > 0 ? attemptCount : null; return { kind: "gateway", - code: publicFailureText(error.code, 120) || "GATEWAY_ERROR", - message: publicFailureText(error.message, 500) || "模型网关请求失败", + code: publicFailureText(error?.code, 120) || publicFailureText(status, 120).toUpperCase() || "GATEWAY_ERROR", + message: publicFailureText(error?.message, 500) || failedTurnFallbackMessage(status), httpStatus, upstreamMessage: gatewayFailureBodyMessage(details?.body), attempts }; } +function failedTurnFallbackMessage(status: string) { + if (status === "gateway_not_configured") { + return "模型网关未配置"; + } + if (status === "vision_unavailable" || status === "vision_error") { + return "视觉识别失败"; + } + if (status === "context_overflow") { + return "上下文超出窗口"; + } + if (status === "tool_limit") { + return "工具轮次已达上限"; + } + return "模型网关请求失败"; +} + /** @param {unknown} body */ diff --git a/src/dashboard/runtime/turn-queue.ts b/src/dashboard/runtime/turn-queue.ts index 4178dde..59e5cad 100644 --- a/src/dashboard/runtime/turn-queue.ts +++ b/src/dashboard/runtime/turn-queue.ts @@ -154,6 +154,7 @@ import { isPlainObject, nonNegativeInteger } from "./util.ts"; +import { appendInterruptedDraftMessages } from "../../core/session-resume.ts"; export async function withIdempotentTurnRequest( @@ -1050,6 +1051,7 @@ export function runTurnInBackground(state: DashboardActiveSessionState, item: Da if (wasQuarantined) { state.status = "interrupted"; } + await persistVisibleInterruptedDraft(state, turnId, env); state.currentPrompt = ""; state.currentAttachmentBytes = 0; const pendingMutation = state.session.pendingModelSelectionMutation; @@ -1109,6 +1111,37 @@ export function runTurnInBackground(state: DashboardActiveSessionState, item: Da } +async function persistVisibleInterruptedDraft(state: DashboardActiveSessionState, turnId: string, env: NodeJS.ProcessEnv | undefined) { + if (state.events.some((event) => event.turnId === turnId && event.type === "assistant_final")) { + return; + } + const messages = activeTranscriptMessages(state); + if (messages.some((message) => Boolean(message) && typeof message === "object" && (message as { interruptedDraft?: unknown }).interruptedDraft === true)) { + return; + } + const fromTurn = state.events + .filter((event) => event.turnId === turnId && event.type === "assistant_draft") + .map((event) => String(event.text ?? "")) + .join(""); + const text = fromTurn.trim() + ? fromTurn + : state.events + .filter((event) => event.type === "assistant_draft") + .map((event) => String(event.text ?? "")) + .join(""); + if (!text.trim()) { + return; + } + const prompt = String(state.currentPrompt ?? ""); + appendInterruptedDraftMessages(state.session, prompt, prompt, { text }, `turn:${state.status || "failed"}`); + try { + await persistSessionSnapshot(state.session, { env, requireExisting: false }); + state.persisted = true; + } catch { + state.persisted = false; + } +} + export function isCurrentTurn(state: DashboardActiveSessionState, controller: unknown, turnId: unknown) { return ownsTurn(state, controller, turnId) && state.quarantinedTurnId !== turnId && !state.disposed; } diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index 88d4fe6..d836a22 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -1592,10 +1592,18 @@ async function persistBackgroundAgentResultIfNeeded( heartbeatAt: now, progressAt: now, latestProgress: interrupted - ? "后台子智能体已中断" + ? (typeof result.output === "string" && result.output.trim() ? "子智能体已中断,已保留工具记录和草稿" : "后台子智能体已中断") : ok ? (partial ? "子智能体阶段性暂停,可继续" : "子智能体已完成") : String(error?.message ?? "后台子智能体异常结束"), + ...(typeof result.output === "string" && result.output.trim() + ? { + outputSummary: result.output.split(/\r?\n/).slice(0, 8).join("\n"), + output: result.output + } + : {}), + ...(Array.isArray(result.tools) ? { toolCalls: result.tools } : {}), + ...(typeof result.continuationPrompt === "string" ? { continuationPrompt: result.continuationPrompt } : {}), error: ok ? null : error }); } diff --git a/tests/unit/agents.test.ts b/tests/unit/agents.test.ts index 9820c0e..e23e3f3 100644 --- a/tests/unit/agents.test.ts +++ b/tests/unit/agents.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { runSubagent } from "../../src/agents/runner.ts"; +import { createAgentTaskStore } from "../../src/agents/task-store.ts"; test("readonly researcher returns workspace summary and grep matches without gateway", async () => { const cwd = await makeTempWorkspace(); @@ -1244,6 +1245,117 @@ test("verifier execute subagent can run validation commands without write tools" } }); +test("interrupted subagent keeps visible draft in the task record for later lookup", async () => { + const cwd = await makeTempWorkspace(); + const controller = new AbortController(); + const server = await listen(http.createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); + return; + } + for await (const _ of request) { + // Drain request body. + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(`data: ${JSON.stringify({ type: "message_start", id: "hanging-subagent", model: "mock-agent" })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "text_delta", text: "half-written explorer draft" })}\n\n`); + }), "127.0.0.1"); + + try { + const pending = runSubagent({ + cwd, + profileName: "explorer", + query: "inspect workspace", + env: mockGatewayEnv(serverUrl(server)), + signal: controller.signal, + taskId: "task-interrupt-draft" + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + controller.abort(); + const result = await pending; + assert.equal(result.interrupted, true); + assert.match(String(result.output ?? ""), /half-written explorer draft/); + const saved = await createAgentTaskStore({ cwd }).readTask("task-interrupt-draft"); + assert.equal(saved.ok, true); + assert.equal(saved.task.status, "interrupted"); + assert.match(String(saved.task.output ?? ""), /half-written explorer draft/); + assert.match(String(saved.task.outputSummary ?? ""), /已中断|half-written/); + } finally { + await close(server); + } +}); + +test("interrupted subagent keeps completed tool calls in the task record", async () => { + const cwd = await makeTempWorkspace(); + await fs.writeFile(path.join(cwd, "notes.txt"), "hello from notes\n", "utf8"); + const controller = new AbortController(); + let hits = 0; + const server = await listen(http.createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); + return; + } + hits += 1; + if (hits === 1) { + const body = await readRequestJson(request); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + id: "mock-agent-tool", + model: body.model, + content: [], + toolCalls: [{ id: "read-notes", name: "read_file", input: { path: "notes.txt", maxBytes: 1024 } }], + stopReason: "tool_calls" + })); + return; + } + for await (const _ of request) { + // Drain request body. + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(`data: ${JSON.stringify({ type: "message_start", id: "hanging-second", model: "mock-agent" })}\n\n`); + response.write(`data: ${JSON.stringify({ type: "text_delta", text: "draft after reading notes" })}\n\n`); + }), "127.0.0.1"); + + try { + const pending = runSubagent({ + cwd, + profileName: "explorer", + query: "read notes then continue", + env: mockGatewayEnv(serverUrl(server)), + signal: controller.signal, + taskId: "task-interrupt-tools" + }); + await new Promise((resolve) => { + const timer = setInterval(() => { + if (hits >= 2) { + clearInterval(timer); + resolve(undefined); + } + }, 20); + setTimeout(() => { + clearInterval(timer); + resolve(undefined); + }, 2000); + }); + controller.abort(); + const result = await pending; + assert.equal(result.interrupted, true); + assert.equal(result.tools[0].name, "read_file"); + assert.equal(result.tools[0].ok, true); + assert.match(String(result.output ?? ""), /read_file|已完成工具/); + const saved = await createAgentTaskStore({ cwd }).readTask("task-interrupt-tools"); + assert.equal(saved.ok, true); + assert.equal(saved.task.status, "interrupted"); + assert.equal(saved.task.toolCalls[0].name, "read_file"); + assert.equal(saved.task.toolCalls[0].ok, true); + assert.match(String(saved.task.output ?? ""), /已完成工具|read_file/); + } finally { + await close(server); + } +}); + async function makeTempWorkspace() { return fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); } diff --git a/tests/unit/dashboard-runtime/lifecycle.ts b/tests/unit/dashboard-runtime/lifecycle.ts index fb2788e..652c35a 100644 --- a/tests/unit/dashboard-runtime/lifecycle.ts +++ b/tests/unit/dashboard-runtime/lifecycle.ts @@ -923,6 +923,31 @@ test("dashboard runtime exposes a redacted gateway failure summary for archived assert.equal("body" in reopened.session.failure, false); }); +test("dashboard runtime exposes a failure summary when gateway rounds are missing", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-")); + const runtime = createDashboardRuntime({ cwd, env: { USERPROFILE: cwd } }); + const store = createSessionStore({ cwd }); + await store.writeMetadata({ + id: "archived-gateway-failure-no-rounds", + prompt: "failed first turn", + title: "failed first turn", + status: "gateway_error", + transcript: { messages: [] } + }); + + const reopened = await runtime.readSession("archived-gateway-failure-no-rounds"); + + assert.equal(reopened.ok, true); + assert.deepEqual(reopened.session.failure, { + kind: "gateway", + code: "GATEWAY_ERROR", + message: "模型网关请求失败", + httpStatus: null, + upstreamMessage: null, + attempts: null + }); +}); + test("dashboard resume sends archived full context while display stays paged", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-")); const requests = []; diff --git a/tests/unit/dashboard-runtime/model.ts b/tests/unit/dashboard-runtime/model.ts index 26f19f2..3d763b5 100644 --- a/tests/unit/dashboard-runtime/model.ts +++ b/tests/unit/dashboard-runtime/model.ts @@ -2233,6 +2233,62 @@ test("dashboard runtime omits the active turn transcript during refresh recovery } }); +test("dashboard runtime keeps interrupted visible drafts in transcript after the turn fails", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-")); + const server = await listen(createHangingStreamGateway(), "127.0.0.1", 0); + try { + const runtime = createDashboardRuntime({ + cwd, + env: mockGatewayEnv(server) + }); + await runtime.trustWorkspace(); + const started = await runtime.startTurn({ + prompt: "keep the streamed draft", + permissionMode: "workspace" + }); + await waitForEvent(runtime, started.sessionId, (event) => event.type === "assistant_draft" && /partial draft/.test(String(event.text ?? ""))); + runtime.interruptTurn(started.sessionId, cleanupAbortError()); + await waitForEvent(runtime, started.sessionId, (event) => event.type === "run_state" && event.running === false); + + const reopened = await runtime.readSession(started.sessionId); + const transcript = JSON.stringify(reopened.session.transcript ?? []); + assert.equal(reopened.session.running, false); + assert.match(transcript, /partial draft/); + assert.equal( + (reopened.session.transcript ?? []).some((message) => message?.interruptedDraft === true || /中断草稿/.test(JSON.stringify(message))), + true + ); + } finally { + await close(server); + } +}); + +test("dashboard runtime keeps thinking-process drafts in transcript after a completed turn", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-")); + const server = await listen(createGateway("stable answer", { textChunks: ["visible draft"] }), "127.0.0.1", 0); + try { + const runtime = createDashboardRuntime({ + cwd, + env: mockGatewayEnv(server) + }); + await runtime.trustWorkspace(); + const started = await runtime.startTurn({ + prompt: "keep thinking process", + permissionMode: "workspace" + }); + await waitForEvent(runtime, started.sessionId, (event) => event.type === "run_state" && event.running === false); + const reopened = await runtime.readSession(started.sessionId); + const transcript = reopened.session.transcript ?? []; + assert.equal( + transcript.some((message) => message?.thinkingProcess === true && /visible draft/.test(JSON.stringify(message))), + true + ); + assert.equal(transcript.some((message) => message?.interruptedDraft === true), false); + } finally { + await close(server); + } +}); + test("dashboard runtime emits workflow snapshots for visible progress", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-")); const server = await listen(createTodoGateway(), "127.0.0.1", 0); diff --git a/tests/unit/dashboard-ui.test.ts b/tests/unit/dashboard-ui.test.ts index 3ac371a..92f9486 100644 --- a/tests/unit/dashboard-ui.test.ts +++ b/tests/unit/dashboard-ui.test.ts @@ -324,6 +324,22 @@ test("dashboard app exposes session actions and reconnects active sessions", asy assert.match(source, /CURRENT_SESSION_STORAGE_KEY/); assert.match(source, /await restoreInitialSession\(\)/); assert.match(source, /initialSessionId\(\) \|\| latestBackgroundSessionId\(\)/); + assert.match(source, /await openSession\(sessionId, \{ restore: !listed \}\)/); + assert.match(source, /export async function openSession\(id: string, options: \{ restore\?: boolean \} = \{\}\)/); + assert.match(source, /options\.restore !== true/); + assert.match(source, /if \(activity\.status === "failed" \|\| activity\.status === "blocked"\) \{/); + assert.match(source, /appendActivity\(\{/); + assert.match(source, /collapseCompletedActivities\(\);\s*clearPendingGuide\(\)/s); + assert.match(source, /event\.type === "assistant_interrupted_draft"/); + assert.match(source, /keepInterruptedAssistantDrafts\(\)/); + assert.match(source, /createInterruptedDraftNode\(message\)/); + assert.match(source, /isThinkingProcessMessage\(message\)/); + assert.match(source, /createThinkingProcessNode\(message\)/); + assert.match(source, /function isInterruptedDraftMessage\(/); + assert.match(source, /message\.interruptedDraft === true/); + assert.match(source, /已中断 · 非最终回复/); + assert.match(source, /function keepInterruptedAssistantDrafts\(/); + assert.match(source, /if \(event\.type === "assistant_interrupted_draft"\) \{\s*keepInterruptedAssistantDrafts\(\);/s); assert.match(source, /function latestBackgroundSessionId\(\)/); assert.match(source, /window\.localStorage\?\.setItem\(CURRENT_SESSION_STORAGE_KEY, id\)/); assert.match(source, /function copySessionId\(sessionId/); diff --git a/tests/unit/session-persist-cases.ts b/tests/unit/session-persist-cases.ts index 71079c9..197627b 100644 --- a/tests/unit/session-persist-cases.ts +++ b/tests/unit/session-persist-cases.ts @@ -340,7 +340,8 @@ test("concurrent session snapshots rebase pending messages onto the latest commi const env = {}; const seed = await createSession({ cwd, mode: "interactive", env }); await runSessionTurn(seed, { prompt: "seed concurrent archive", env }); - const baseTotal = seed.transcriptArchive.totalMessages; + const baseTranscriptTotal = seed.transcriptArchive.totalMessages; + const baseModelTotal = seed.modelContextArchive.totalMessages; const left = await createSession({ cwd, mode: "interactive", env, resume: seed.id }); const right = await createSession({ cwd, mode: "interactive", env, resume: seed.id }); @@ -368,8 +369,8 @@ test("concurrent session snapshots rebase pending messages onto the latest commi visibleRoles: ["user", "assistant", "tool"] }); assert.equal(saved.ok, true); - assert.equal(saved.metadata.transcript.archive.totalMessages, baseTotal + 2); - assert.equal(saved.metadata.transcript.modelArchive.totalMessages, baseTotal + 2); + assert.equal(saved.metadata.transcript.archive.totalMessages, baseTranscriptTotal + 2); + assert.equal(saved.metadata.transcript.modelArchive.totalMessages, baseModelTotal + 2); assert.equal(transcript.messages.some((message) => message.content === "concurrent left message"), true); assert.equal(transcript.messages.some((message) => message.content === "concurrent right message"), true); assert.equal(modelContext.messages.some((message) => message.content === "concurrent left message"), true); diff --git a/tests/unit/session-turn-cases.ts b/tests/unit/session-turn-cases.ts index 2f0df3d..39420fc 100644 --- a/tests/unit/session-turn-cases.ts +++ b/tests/unit/session-turn-cases.ts @@ -928,6 +928,9 @@ test("session reports gateway_error after normalized missing terminal retries ar assert.equal(metadata.status, "gateway_error"); assert.equal(metadata.gatewayErrors.includes("UPSTREAM_STREAM_ABORTED"), true); assert.equal(metadataText.includes("模型本轮没有返回可展示正文"), false); + const visibleTranscript = JSON.stringify(metadata.transcript?.messages ?? []); + assert.match(visibleTranscript, /do not accept an incomplete response/); + assert.match(visibleTranscript, /UPSTREAM_STREAM_ABORTED/); } finally { if (transcriptEnabled === undefined) { delete env.LAB_AGENT_TRANSCRIPT_ENABLED; @@ -1704,7 +1707,8 @@ test("streamed thinking persists into resume context and follows later turns", a assert.equal(session.messages[1].thinking.text, "checking "); const metadataPath = path.join(cwd, ".lab-agent", "sessions", `${session.id}.json`); const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); - assert.equal(metadata.transcript.messages[1].thinking.text, "checking "); + const persistedAssistant = (metadata.transcript.messages ?? []).find((message) => message.role === "assistant" && message.thinkingProcess !== true && message.interruptedDraft !== true); + assert.equal(persistedAssistant.thinking.text, "checking "); const resumed = await createSession({ cwd, @@ -2159,6 +2163,104 @@ test("gateway failures persist streamed assistant draft for resume", async () => } }); +test("thinking-only gateway disconnect keeps thinking in context and a transcript marker", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); + const originalFetch = globalThis.fetch; + const requests = []; + const events = []; + const encoder = new TextEncoder(); + let fetchCalls = 0; + + try { + globalThis.fetch = async (_url, options) => { + fetchCalls += 1; + requests.push(JSON.parse(String(options.body ?? "{}"))); + if (fetchCalls > 1) { + return new Response(JSON.stringify({ + id: "chatcmpl-resume-ok", + model: "mock-openai", + choices: [ + { + finish_reason: "stop", + message: { + role: "assistant", + content: "continued after thinking-only disconnect" + } + } + ] + }), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + let streamStep = 0; + const body = new ReadableStream({ + pull(controller) { + streamStep += 1; + if (streamStep === 1) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-thinking-only","model":"mock-openai","choices":[{"delta":{"reasoning_content":"Need to inspect the long-running phase before answering."}}]}\n\n')); + return; + } + controller.error(new Error("premature close")); + } + }); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" } + }); + }; + + const env = { + LAB_MODEL_GATEWAY_URL: "http://127.0.0.1/v1/chat/completions", + LAB_AGENT_MODEL: "mock-openai", + LAB_AGENT_NETWORK_MODE: "offline", + LAB_MODEL_GATEWAY_PROTOCOL: "openai-chat", + LAB_MODEL_GATEWAY_MAX_RETRIES: "0" + }; + const session = await createSession({ + cwd, + mode: "interactive", + env + }); + + const result = await runSessionTurn(session, { + prompt: "continue the long thinking task", + env, + onEvent: (event) => events.push(event) + }); + + assert.match(result.output, /Gateway error: GATEWAY_(RESPONSE_PARSE_ERROR|STREAM_INTERRUPTED)/); + assert.equal(session.messages[1].interruptedDraft, true); + assert.equal(Array.isArray(session.messages[1].content) && session.messages[1].content.length === 0, true); + assert.match(String(session.messages[1].thinking?.text ?? ""), /inspect the long-running phase/); + assert.equal(JSON.stringify(session.messages).includes("中断草稿,非最终回复"), false); + assert.equal(events.some((event) => event.type === "assistant_interrupted_draft"), true); + + const metadataPath = path.join(cwd, ".lab-agent", "sessions", `${session.id}.json`); + const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); + assert.match(JSON.stringify(metadata.transcript.messages), /中断草稿,非最终回复/); + assert.match(JSON.stringify(metadata.transcript.messages), /可见正文前被上游断开/); + assert.equal(metadata.interruptedDraft.thinkingBytes > 0, true); + + const resumed = await createSession({ + cwd, + mode: "interactive", + env, + resume: session.id + }); + await runSessionTurn(resumed, { + prompt: "continue after the disconnect", + env + }); + const resumeAssistant = requests[1].messages.find((message) => message.role === "assistant"); + assert.ok(resumeAssistant); + assert.equal(resumeAssistant.reasoning_content, "Need to inspect the long-running phase before answering."); + assert.equal(JSON.stringify(requests[1]).includes("中断草稿,非最终回复"), false); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("session turns interrupt an in-flight shell tool and finish locally", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); const requests = [];