diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index f99fcfd689..894d0f5e0f 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -13,6 +13,8 @@ import { Check, ChevronLeft, ChevronRight, + Clock, + Hourglass, CircleSlash, Copy, Database, @@ -104,6 +106,7 @@ import { import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates"; import { deleteConversationWithCancellation, stopAiGenerationWithFallback } from "@/lib/ai/aiConversationLifecycle"; import { AiGenerationGuard } from "@/lib/ai/aiGenerationGuard"; +import { applyStatusEvent, createGenerationStatus, createStatusTicker, liveAnnouncementText, markCancelling, shouldShowLongRunningHint, statusText, toolLabel, STATUS_IDLE_THRESHOLD_MS, type AiGenerationStatus } from "@/lib/ai/aiGenerationStatus"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { addConfiguredAiModel, aiModelOptions } from "@/lib/ai/aiConfigList"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; @@ -116,7 +119,7 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue"; -import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; +import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter"; import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender"; import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown"; @@ -375,6 +378,72 @@ let currentAssistantMessageIndex = -1; // instead of relying on isGenerating/currentSessionId alone. const aiGenerationGuard = new AiGenerationGuard(); +// Live generation-status line (Issue #6743 feature 1). `generationStatus` is the +// per-request state machine fed by every `ai-agent-event`; `statusNow` is bumped +// by the whole-second ticker (`createStatusTicker`, lib/ai/aiGenerationStatus.ts) +// only when the displayed whole second changes, so `statusText` recomputes +// elapsed/idle at each real second boundary instead of a fixed 1s interval (a +// delayed interval tick used to skip values — Math.ceil of a late wall-clock +// sample — and freeze the display in between). The wall-clock `setTimeout` +// replaces a per-frame rAF loop that rescheduled ~60×/s while only updating once +// per second, and keeps ticking while the document is hidden (rAF pauses then). +// Both refs are per-request transient state and MUST be reset on both the normal +// `finally` path and `resetPendingRequestState()` (abandon path) — see the +// dual-path note next to `resetPendingRequestState`. +const generationStatus = ref(createGenerationStatus(Date.now())); +const statusNow = ref(Date.now()); +/** Last displayed whole second (`Math.ceil((statusNow - startedAt) / 1000)`). */ +let lastStatusSecond = -1; + +// Aligned-to-the-next-second ticker. The callback mirrors the old +// requestAnimationFrame body: write `statusNow` only when the displayed whole +// second changes, so the display rolls +1s at each real boundary. +const statusTicker = createStatusTicker((now: number) => { + const second = Math.ceil((now - generationStatus.value.startedAt) / 1000); + if (second !== lastStatusSecond) { + lastStatusSecond = second; + statusNow.value = now; + } +}); + +function startStatusTimer() { + const now = Date.now(); + statusNow.value = now; + // Seed the boundary so the ticker writes only when the displayed whole second + // changes — the display then rolls +1s within ~a frame of each real boundary + // instead of skipping values when a tick is delayed. + lastStatusSecond = Math.ceil((now - generationStatus.value.startedAt) / 1000); + statusTicker.start(now); +} + +function stopStatusTimer() { + statusTicker.stop(); +} + +const generationStatusText = computed(() => statusText(generationStatus.value, statusNow.value, t)); +const statusElapsedSeconds = computed(() => Math.max(0, Math.ceil((statusNow.value - generationStatus.value.startedAt) / 1000))); +const statusIdleSeconds = computed(() => (generationStatus.value.lastEventAt !== undefined ? Math.max(0, Math.ceil((statusNow.value - generationStatus.value.lastEventAt) / 1000)) : 0)); +/** Idle copy branch: an event was seen, but nothing has arrived for over 20s. */ +const generationStatusIdle = computed(() => { + const last = generationStatus.value.lastEventAt; + return last !== undefined && statusNow.value - last > STATUS_IDLE_THRESHOLD_MS; +}); +const generationStatusRunningTool = computed(() => generationStatus.value.phase === "running_tool" && !!generationStatus.value.activeTool); +const statusToolLabel = computed(() => { + const tool = generationStatus.value.activeTool; + return tool ? toolLabel(tool.name, t) : ""; +}); +const statusTurnBadge = computed(() => (generationStatus.value.turn !== undefined ? t("ai.status.turnBadge", { turn: generationStatus.value.turn + 1 }) : "")); +/** Gentle >60s hint, hidden while the user is cancelling (they already decided to stop). */ +const statusLongRunningHintVisible = computed(() => generationStatus.value.phase !== "cancelling" && generationStatus.value.phase !== "finalizing" && generationStatus.value.phase !== "finished" && shouldShowLongRunningHint(generationStatus.value, statusNow.value)); +/** + * Stable screen-reader announcement for the status line. Fed into a + * `role="status"` live region; unlike `generationStatusText` it excludes the + * per-second elapsed/idle numerals so screen readers hear discrete state + * changes (phase / tool / turn / idle crossing), not a new number every tick. + */ +const statusLiveAnnouncement = computed(() => liveAnnouncementText(generationStatus.value, statusNow.value, t)); + function startEditMessage(visibleIndex: number) { if (isGenerating.value) return; editingMessageIndex.value = visibleIndex; @@ -906,11 +975,6 @@ function messageTitle(message: ChatMessage): string { return [messageMentionLabels(message).join(" "), message.content].filter(Boolean).join(" ") || t("ai.newChat"); } -const isWaitingForFirstDelta = computed(() => { - const last = messages.value[messages.value.length - 1]; - return isGenerating.value && last?.role === "assistant" && !last.content && !last.reasoning; -}); - /** * The last assistant message whose final line looks like an action * proposal question. Used to render an inline "Yes / No" confirmation bar @@ -1230,6 +1294,12 @@ function agentStepClass(tone: AiAgentStepTone): string { } } +/** True when a step renders a right-aligned tail: a running tool step + * (spinner + "executing") or a completed tool step with a computed duration. */ +function agentStepHasTail(step: AiAgentStepItem): boolean { + return (step.tone === "active" && !!step.toolName) || step.durationMs !== undefined; +} + /** Extract tool result content from the AgentEvent result value */ function extractToolResultContent(result: unknown): string | undefined { if (!result) return undefined; @@ -1273,7 +1343,7 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla } } -function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | undefined { +function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined { if (event.type === "context_compacted") { return { key: `compact-${index}`, @@ -1296,6 +1366,7 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u tone: "active", toolName: event.tool_name, toolArgs: event.args as Record, + startedAtMs: now, }; } @@ -1312,6 +1383,7 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u toolResult: extractToolResultContent(event.result), explainData: extractExplainData(event.result), isError: event.is_error, + endedAtMs: now, }; } @@ -2352,11 +2424,15 @@ async function send() { // first, since clearMessages()/selectConversation() can invalidate it out from // under an in-flight send(). isGenerating.value = true; + generationStatus.value = createGenerationStatus(Date.now()); + startStatusTimer(); const myGeneration = aiGenerationGuard.begin(); if (!(await promptTemplateStore.ensureLoaded())) { clearPendingWriteGrant(); if (aiGenerationGuard.isCurrent(myGeneration)) { isGenerating.value = false; + stopStatusTimer(); + generationStatus.value = createGenerationStatus(Date.now()); toast(t("ai.customInstructionsLoadFailed"), 5000); } return; @@ -2490,6 +2566,10 @@ async function send() { // been registered with the backend yet (registration happens inside // runAgentStream() itself). if (!aiGenerationGuard.isCurrent(myGeneration)) return; + // The stream is about to reach the backend — transition the status line from + // `preparing` to `waiting_model` so it reads "等待模型响应" while no events have + // arrived yet (slow CLI first token included). + generationStatus.value = { ...generationStatus.value, phase: "waiting_model" }; const history: AiMessage[] = messagesForAgentHistory(messages.value.slice(0, -2)); await runAgentStream( { @@ -2513,6 +2593,18 @@ async function send() { // shared state to write into. if (!aiGenerationGuard.isCurrent(myGeneration)) return; agentEvents.push(event); + // Feed every agent event into the generation-status state machine (Issue + // #6743 feature 1). `applyStatusEvent` refreshes lastEventAt, tracks the + // active tool / turn, and derives the phase purely from the event stream. + generationStatus.value = applyStatusEvent(generationStatus.value, event, Date.now()); + // Terminal event (agent_end / error) hides the status line immediately — + // the backend promise may still be settling (CLI teardown / SSE close), so + // stop the ticker now instead of letting it idle through that gap. The + // non-terminal `response_complete` (phase=finalizing) hides the line the + // same way, but the listener stays alive for the real agent_end/error. + if (generationStatus.value.phase === "finished" || generationStatus.value.phase === "finalizing") { + stopStatusTimer(); + } if (event.type === "text_delta" && event.delta) { appendAssistantDelta(assistantIdx, event.delta); } @@ -2530,8 +2622,11 @@ async function send() { appendAssistantReasoning(assistantIdx, event.delta); } if (event.type === "agent_end") { + // End the card's "思考过程" spinner at the terminal event rather than + // waiting for send()'s finally (which can lag behind CLI teardown). + const msg = messages.value[assistantIdx]; + if (msg) msg.isThinking = false; if (event.input_tokens || event.output_tokens) { - const msg = messages.value[assistantIdx]; if (msg) msg.tokens = { input: event.input_tokens ?? 0, output: event.output_tokens ?? 0 }; } } @@ -2539,7 +2634,7 @@ async function send() { const msg = messages.value[assistantIdx]; if (msg) { if (!msg.agentSteps) msg.agentSteps = []; - const step = agentEventToStep(event, agentEvents.length - 1); + const step = agentEventToStep(event, agentEvents.length - 1, Date.now()); if (step) upsertAgentStep(msg.agentSteps, step); } pendingCompaction.value = { summary: event.summary, compactedMessages: event.compacted_messages }; @@ -2549,7 +2644,7 @@ async function send() { const msg = messages.value[assistantIdx]; if (msg) { if (!msg.agentSteps) msg.agentSteps = []; - const step = agentEventToStep(event, agentEvents.length - 1); + const step = agentEventToStep(event, agentEvents.length - 1, Date.now()); if (step) upsertAgentStep(msg.agentSteps, step); } } @@ -2586,11 +2681,16 @@ async function send() { const msg = messages.value[assistantIdx]; if (msg) msg.isThinking = false; isGenerating.value = false; + // Normal-path generation-status cleanup (dual-path reset — see + // resetPendingRequestState() below for the abandon-path equivalent). + stopStatusTimer(); + generationStatus.value = createGenerationStatus(Date.now()); + statusNow.value = Date.now(); // Render agent tool call steps from agent events (fallback when no real-time steps) if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) { const steps: AiAgentStepItem[] = []; agentEvents.forEach((e, index) => { - const step = agentEventToStep(e, index); + const step = agentEventToStep(e, index, Date.now()); if (step) upsertAgentStep(steps, step); }); if (steps.length) msg.agentSteps = steps; @@ -2653,6 +2753,12 @@ function waitForGenerationToClear(timeoutMs: number): Promise { } async function cancelStream() { + // User explicitly requested stop — reflect it in the status line (phase=cancelling) + // so it reads "正在取消…" while the backend cancellation is still settling. + if (isGenerating.value) { + generationStatus.value = markCancelling(generationStatus.value, Date.now()); + statusNow.value = Date.now(); + } await stopAiGenerationWithFallback({ isGenerating: () => isGenerating.value, currentGeneration: () => aiGenerationGuard.peek(), @@ -2688,6 +2794,13 @@ function resetPendingRequestState() { pendingAssistantReasoning = ""; pendingAssistantIndex = -1; pendingCompaction.value = null; + // Abandon-path generation-status cleanup: a clear/switch/new-chat/unmount must + // stop the status ticker and clear the per-request status (and its `now` ref), + // otherwise switching conversations leaks a stale status line into the next + // generation. + stopStatusTimer(); + generationStatus.value = createGenerationStatus(Date.now()); + statusNow.value = Date.now(); } // `alreadyCancelledSessionId`: the session id a caller (cancelStream()) has @@ -2985,6 +3098,7 @@ onUnmounted(() => { if (assistantDeltaFrame !== null) cancelAnimationFrame(assistantDeltaFrame); clearTimeout(mentionTimer); clearEffortMenuCloseTimer(); + stopStatusTimer(); // Must invalidate the generation the same way clearMessages()/selectConversation() // do, not just fire the best-effort cancelStream() RPC: if a request is still // mid-await (context preparation, or the backend hasn't registered a session id @@ -3317,10 +3431,18 @@ async function openExternalUrl(url: string) {
{{ step.toolArgs.sql }}
@@ -3407,9 +3529,39 @@ async function openExternalUrl(url: string) {
-
- - {{ t("ai.thinking") }} + +
+ + {{ statusLiveAnnouncement }} +
@@ -3624,6 +3776,12 @@ async function openExternalUrl(url: string) { @paste="onPromptPaste" /> + +
+ + {{ t("ai.status.longRunningHint") }} +
diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts index 10e8d97c7e..1b5c5f8603 100644 --- a/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts @@ -147,6 +147,50 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P const sessionResetIdx = finallyBody.indexOf('currentSessionId.value = "";'); expect(isGeneratingIdx).toBeGreaterThan(guardIdx); expect(sessionResetIdx).toBeGreaterThan(guardIdx); + // Issue #6743 feature 1 dual-path reset: the normal-completion path must stop + // the 1s status timer and clear the generation-status ref inside the guarded + // finally (the abandon path clears it via resetPendingRequestState()). + expect(finallyBody.indexOf("stopStatusTimer();")).toBeGreaterThan(guardIdx); + expect(finallyBody.indexOf("generationStatus.value = createGenerationStatus(Date.now());")).toBeGreaterThan(guardIdx); + }); + + it("the generation-status line exposes a screen-reader live region (role=status) that excludes ticking numerals", () => { + // Issue #6743 feature 1 a11y: async execution-state updates must be + // announced. The status block (data-ai-generation-status) must contain a + // role="status" live region fed by `statusLiveAnnouncement` — which, unlike + // the visible `statusText`, omits the per-second elapsed/idle numerals so a + // screen reader hears discrete state changes, not a timer ticking every 1s. + const statusBlockStart = source.indexOf("data-ai-generation-status"); + expect(statusBlockStart).toBeGreaterThanOrEqual(0); + const block = source.slice(statusBlockStart); + expect(block).toContain('role="status"'); + expect(block).toContain('aria-live="polite"'); + expect(block).toContain('aria-atomic="true"'); + expect(block).toContain("statusLiveAnnouncement"); + expect(block).toContain('class="sr-only"'); + }); + + it("the generation-status line hides once the generation is finished (agent_end) even before isGenerating clears", () => { + // Issue #6743 fix: agent_end/error arrive via the event callback before + // runAgentStream()'s promise resolves (CLI teardown / SSE close can take + // seconds), so the status line must ALSO be gated on phase !== 'finished' — + // otherwise it lingers below the completed reply showing a reset "0s". + const statusLineIdx = source.indexOf("data-ai-generation-status"); + expect(statusLineIdx).toBeGreaterThanOrEqual(0); + const lineStart = source.lastIndexOf("\n", statusLineIdx) + 1; + const lineEnd = source.indexOf("\n", statusLineIdx); + const openingTag = source.slice(lineStart, lineEnd); + expect(openingTag).toContain("generationStatus.phase !== 'finished'"); + expect(openingTag).toContain("data-ai-generation-status"); + }); + + it("the >60s long-running hint is hidden once the generation is finished", () => { + // Fix keeps startedAt on the finished phase, so the hint must not reappear + // under a completed reply during the isGenerating-still-true gap. + const idx = source.indexOf("const statusLongRunningHintVisible"); + expect(idx).toBeGreaterThanOrEqual(0); + const line = source.slice(idx, source.indexOf("\n", idx)); + expect(line).toContain('"finished"'); }); // The three gaps below were called out on review of PR #6332: the generation guard @@ -224,6 +268,12 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P // compaction summary into the NEW conversation's transcript in its finally // block. expect(resetBody).toContain("pendingCompaction.value = null;"); + // Issue #6743 feature 1: the live generation-status line is per-request + // transient state too — the 1s status timer and the status ref must be reset + // here, otherwise switching conversations leaks a stale status line (and a + // running interval) into the next generation. + expect(resetBody).toContain("stopStatusTimer();"); + expect(resetBody).toContain("generationStatus.value = createGenerationStatus(Date.now());"); const abandonBody = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)"); expect(abandonBody).toContain("resetPendingRequestState();"); @@ -239,4 +289,29 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();"); expect(body).not.toContain("cancelStream();"); }); + + it("agent step cards render a running-tool tail and a computed duration tail", () => { + // Issue #6743 (feature-1 gap): per-tool execution time in the agent step cards — + // mockup shows a spinner + "执行中…" tail on running steps and `0.8s`/`1.2s` on + // completed steps. The step-row template must special-case running tool steps + // (spinner icon + executing tail) and completed tool steps (tabular duration). + const stepsStart = source.indexOf('v-for="step in msg.agentSteps"'); + expect(stepsStart, "expected to find the agent-steps v-for in AiAssistant.vue").toBeGreaterThanOrEqual(0); + const stepsBlock = source.slice(stepsStart); + // Running tool step: spinner leading icon + right-aligned "executing…" tail. + expect(stepsBlock).toContain("step.tone === 'active' && step.toolName"); + expect(stepsBlock).toContain('t("ai.agentSteps.executing")'); + // Completed tool step: right-aligned tabular duration tail. + expect(stepsBlock).toContain("formatToolDurationMs(step.durationMs)"); + }); + + it("the status-line idle branch swaps Clock for Hourglass (mockup alignment)", () => { + // Mockup: idle state shows a non-spinning hourglass (spinner animation stops); + // only the >60s hint below keeps the Clock. The swap must live in the status + // line's spinner/clock slot, not touch the hint. + const statusLineIdx = source.indexOf("data-ai-generation-status"); + expect(statusLineIdx).toBeGreaterThanOrEqual(0); + const statusBlock = source.slice(statusLineIdx); + expect(statusBlock).toContain(" { + describe("toolCallStepKey (stable merge key)", () => { + it("merges start/end for a real tool_call_id", () => { + expect(toolCallStepKey("call-1", 0, "tool_call_start")).toBe(toolCallStepKey("call-1", 1, "tool_call_end")); + }); + + it("keeps missing/repeating fallback ids event-specific so unrelated calls cannot collapse", () => { + expect(toolCallStepKey("", 0, "tool_call_start")).not.toBe(toolCallStepKey("", 0, "tool_call_end")); + expect(toolCallStepKey("cli-tool-call", 0, "tool_call_start")).not.toBe(toolCallStepKey("cli-tool-call", 1, "tool_call_end")); + }); + }); + + describe("upsertAgentStep (duration merge)", () => { + it("merges the end step onto the start card, computing durationMs and preserving startedAtMs", () => { + const steps: AiAgentStepItem[] = []; + upsertAgentStep(steps, startStep); + upsertAgentStep(steps, { + key: "tool-call-1", + labelKey: "ai.agentSteps.toolDone", + tone: "success", + toolName: "list_tables", + toolResult: "…", + isError: false, + endedAtMs: 1_800, + }); + + expect(steps).toHaveLength(1); + const merged = steps[0]; + expect(merged.durationMs).toBe(800); + expect(merged.startedAtMs).toBe(1_000); + expect(merged.endedAtMs).toBe(1_800); + // Details gathered from the start card survive the merge. + expect(merged.toolArgs).toEqual({ sql: "SHOW TABLES" }); + }); + + it("does not compute durationMs for an end step without a seen start", () => { + const steps: AiAgentStepItem[] = []; + upsertAgentStep(steps, { + key: "tool-call-2", + labelKey: "ai.agentSteps.toolDone", + tone: "success", + toolName: "execute_query", + toolResult: "ok", + isError: false, + endedAtMs: 5_000, + }); + + expect(steps).toHaveLength(1); + expect(steps[0].durationMs).toBeUndefined(); + }); + + it("clamps a negative duration to 0 (out-of-order stamps)", () => { + const steps: AiAgentStepItem[] = []; + upsertAgentStep(steps, { ...startStep, startedAtMs: 2_000 }); + upsertAgentStep(steps, { + key: "tool-call-1", + labelKey: "ai.agentSteps.toolDone", + tone: "success", + toolName: "list_tables", + endedAtMs: 500, + }); + + expect(steps[0].durationMs).toBe(0); + }); + }); + + describe("formatToolDurationMs", () => { + it("formats sub-second durations with one decimal", () => { + expect(formatToolDurationMs(799)).toBe("0.8s"); + expect(formatToolDurationMs(800)).toBe("0.8s"); + }); + + it("formats 1s–10s with one decimal", () => { + expect(formatToolDurationMs(1_000)).toBe("1.0s"); + expect(formatToolDurationMs(1_200)).toBe("1.2s"); + expect(formatToolDurationMs(9_999)).toBe("10.0s"); + }); + + it("formats ≥10s as an integer", () => { + expect(formatToolDurationMs(10_000)).toBe("10s"); + expect(formatToolDurationMs(12_000)).toBe("12s"); + expect(formatToolDurationMs(65_432)).toBe("65s"); + }); + }); +}); diff --git a/apps/desktop/src/lib/ai/__tests__/aiGenerationStatus.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiGenerationStatus.spec.ts new file mode 100644 index 0000000000..5d599946e5 --- /dev/null +++ b/apps/desktop/src/lib/ai/__tests__/aiGenerationStatus.spec.ts @@ -0,0 +1,471 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyStatusEvent, createGenerationStatus, createStatusTicker, liveAnnouncementText, markCancelling, nextStatusTickDelay, shouldShowLongRunningHint, statusText, toolLabel, STATUS_IDLE_THRESHOLD_MS, STATUS_LONG_RUNNING_THRESHOLD_MS, type AiStatusTranslate } from "@/lib/ai/aiGenerationStatus"; + +// Mock vue-i18n `t` mirroring the zh-CN copy matrix (Issue #6743 feature 1). +const t: AiStatusTranslate = (key: string, named?: Record) => { + switch (key) { + case "ai.status.waitingModel": + return `等待模型响应 · 已运行 ${named?.elapsed}s`; + case "ai.status.waitingModelLive": + return "等待模型响应"; + case "ai.status.generating": + return `正在生成回复 · 已运行 ${named?.elapsed}s`; + case "ai.status.generatingLive": + return "正在生成回复"; + case "ai.status.runningTool": + return `第 ${named?.turn} 轮 · 正在执行 ${named?.tool} · 已运行 ${named?.elapsed}s`; + case "ai.status.runningToolAction": + return "· 正在执行"; + case "ai.status.turnBadge": + return `第 ${named?.turn} 轮`; + case "ai.status.idle": + return `等待此步骤完成 · 最后活动 ${named?.idle}s 前`; + case "ai.status.idleLive": + return "等待此步骤完成"; + case "ai.status.idleWithTool": + return `等待此步骤完成 · 最后活动 ${named?.idle}s 前 · 正在执行 ${named?.tool}`; + case "ai.status.cancelling": + return "正在取消…"; + case "ai.status.toolLabels.executeSql": + return "执行 SQL"; + case "ai.status.toolLabels.listTables": + return "列出数据表"; + default: + return key; + } +}; + +const T0 = 1_000_000_000; + +describe("aiGenerationStatus", () => { + describe("createGenerationStatus", () => { + it("starts in preparing with startedAt = now and no event state", () => { + const status = createGenerationStatus(T0); + expect(status).toEqual({ startedAt: T0, phase: "preparing" }); + expect(status.lastEventAt).toBeUndefined(); + expect(status.turn).toBeUndefined(); + expect(status.activeTool).toBeUndefined(); + }); + }); + + describe("applyStatusEvent (event-driven phase transitions)", () => { + it("any event refreshes lastEventAt", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 5_000); + expect(status.lastEventAt).toBe(T0 + 5_000); + status = applyStatusEvent(status, { type: "context_compacted", summary: "…", summary_tokens: 0, compacted_messages: 1, estimated_before: 10, estimated_after: 5 }, T0 + 9_000); + expect(status.lastEventAt).toBe(T0 + 9_000); + }); + + it("turn_start records the 0-based turn", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 1 }, T0 + 1_000); + expect(status.turn).toBe(1); + }); + + it("tool_call_start enters running_tool and sets activeTool", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + expect(status.phase).toBe("running_tool"); + expect(status.activeTool).toEqual({ name: "execute_sql", startedAt: T0 + 1_000 }); + }); + + it("tool_call_end clears activeTool and falls back to generating (regression: activeTool must be cleared)", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_end", tool_call_id: "c1", tool_name: "execute_sql", result: {}, is_error: false }, T0 + 3_000); + expect(status.activeTool).toBeUndefined(); + expect(status.phase).toBe("generating"); + }); + + it("a second tool_call_start while one is active replaces activeTool (no stuck stale tool)", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c2", tool_name: "list_tables", args: {} }, T0 + 2_000); + expect(status.phase).toBe("running_tool"); + expect(status.activeTool).toEqual({ name: "list_tables", startedAt: T0 + 2_000 }); + }); + + it("keeps the newest running tool visible when an earlier parallel tool completes", () => { + // `agent_loop` emits every ToolCallStart before executing read-only tools + // in parallel. Results can complete in a different order, so ending c1 + // must not erase the still-running c2 status. + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "list_tables", args: {} }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c2", tool_name: "get_columns", args: {} }, T0 + 2_000); + status = applyStatusEvent(status, { type: "tool_call_end", tool_call_id: "c1", tool_name: "list_tables", result: {}, is_error: false }, T0 + 3_000); + + expect(status.phase).toBe("running_tool"); + expect(status.activeTool).toEqual({ name: "get_columns", startedAt: T0 + 2_000 }); + + status = applyStatusEvent(status, { type: "tool_call_end", tool_call_id: "c2", tool_name: "get_columns", result: {}, is_error: false }, T0 + 4_000); + expect(status.phase).toBe("generating"); + expect(status.activeTool).toBeUndefined(); + }); + + it("reasoning_delta without an active tool enters generating", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "reasoning_delta", delta: "think" }, T0 + 1_000); + expect(status.phase).toBe("generating"); + }); + + it("text_delta while a tool is active does not overwrite running_tool", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + status = applyStatusEvent(status, { type: "text_delta", delta: "partial" }, T0 + 2_000); + expect(status.phase).toBe("running_tool"); + expect(status.activeTool).toBeDefined(); + }); + + it("write_sql_confirmation_required / production_write_blocked / context_compacted only refresh lastEventAt, never the phase", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "write_sql_confirmation_required", sql: "UPDATE t SET x=1" }, T0 + 2_000); + expect(status.phase).toBe("preparing"); + expect(status.lastEventAt).toBe(T0 + 2_000); + status = applyStatusEvent(status, { type: "production_write_blocked", sql: "DELETE FROM t" }, T0 + 3_000); + expect(status.phase).toBe("preparing"); + status = applyStatusEvent(status, { type: "context_compacted", summary: "…", summary_tokens: 0, compacted_messages: 1, estimated_before: 10, estimated_after: 5 }, T0 + 4_000); + expect(status.phase).toBe("preparing"); + expect(status.lastEventAt).toBe(T0 + 4_000); + }); + + it("agent_end is terminal: enters finished, preserves startedAt/turn, clears activeTool", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 1 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 2_000); + status = applyStatusEvent(status, { type: "agent_end" }, T0 + 3_000); + expect(status.phase).toBe("finished"); + expect(status.activeTool).toBeUndefined(); + // lastEventAt is refreshed to the terminal event time; startedAt/turn survive + // so the elapsed counter never resets to 0 on completion. + expect(status.lastEventAt).toBe(T0 + 3_000); + expect(status.startedAt).toBe(T0); + expect(status.turn).toBe(1); + }); + + it("error is terminal: enters finished without resetting startedAt", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "text_delta", delta: "hi" }, T0 + 1_000); + status = applyStatusEvent(status, { type: "error", message: "boom" }, T0 + 2_000); + expect(status.phase).toBe("finished"); + expect(status.lastEventAt).toBe(T0 + 2_000); + expect(status.startedAt).toBe(T0); + }); + + it("once finished, later agent events do not overwrite the finished phase", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "text_delta", delta: "hi" }, T0 + 1_000); + status = applyStatusEvent(status, { type: "agent_end" }, T0 + 2_000); + status = applyStatusEvent(status, { type: "text_delta", delta: "late" }, T0 + 3_000); + expect(status.phase).toBe("finished"); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c2", tool_name: "execute_sql", args: {} }, T0 + 4_000); + expect(status.phase).toBe("finished"); + expect(status.activeTool).toBeUndefined(); + }); + + it("response_complete enters finalizing: clears activeTool, preserves startedAt/turn, refreshes lastEventAt", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 2 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 2_000); + status = applyStatusEvent(status, { type: "response_complete" }, T0 + 3_000); + expect(status.phase).toBe("finalizing"); + expect(status.activeTool).toBeUndefined(); + expect(status.lastEventAt).toBe(T0 + 3_000); + expect(status.startedAt).toBe(T0); + expect(status.turn).toBe(2); + }); + + it("once finalizing, non-terminal events (text_delta / tool_call_start) do not flip the phase back", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "text_delta", delta: "hi" }, T0 + 1_000); + status = applyStatusEvent(status, { type: "response_complete" }, T0 + 2_000); + status = applyStatusEvent(status, { type: "text_delta", delta: "late" }, T0 + 3_000); + expect(status.phase).toBe("finalizing"); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c2", tool_name: "execute_sql", args: {} }, T0 + 4_000); + expect(status.phase).toBe("finalizing"); + expect(status.activeTool).toBeUndefined(); + status = applyStatusEvent(status, { type: "response_complete" }, T0 + 5_000); + expect(status.phase).toBe("finalizing"); + }); + + it("agent_end / error advance from finalizing to the terminal finished phase", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "response_complete" }, T0 + 1_000); + status = applyStatusEvent(status, { type: "agent_end" }, T0 + 2_000); + expect(status.phase).toBe("finished"); + expect(status.startedAt).toBe(T0); + + let failed = applyStatusEvent(createGenerationStatus(T0), { type: "response_complete" }, T0 + 1_000); + failed = applyStatusEvent(failed, { type: "error", message: "exit 1" }, T0 + 2_000); + expect(failed.phase).toBe("finished"); + }); + + it("markCancelling on a finalizing status is a no-op", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "response_complete" }, T0 + 2_000); + expect(markCancelling(status, T0 + 3_000).phase).toBe("finalizing"); + }); + + it("statusText / liveAnnouncementText return an empty string for finalizing", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "response_complete" }, T0 + 1_000); + expect(statusText(status, T0 + 42_000, t)).toBe(""); + expect(liveAnnouncementText(status, T0 + 42_000, t)).toBe(""); + }); + + it("shouldShowLongRunningHint is false for finalizing", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "response_complete" }, T0 + 1_000); + expect(shouldShowLongRunningHint(status, T0 + STATUS_LONG_RUNNING_THRESHOLD_MS + 1)).toBe(false); + }); + + it("markCancelling on a finished status is a no-op", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "agent_end" }, T0 + 2_000); + expect(markCancelling(status, T0 + 3_000).phase).toBe("finished"); + }); + + it("once cancelling, later agent events do not overwrite the cancelling phase", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "text_delta", delta: "hi" }, T0 + 1_000); + status = markCancelling(status, T0 + 2_000); + status = applyStatusEvent(status, { type: "text_delta", delta: "late" }, T0 + 3_000); + expect(status.phase).toBe("cancelling"); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c2", tool_name: "execute_sql", args: {} }, T0 + 4_000); + expect(status.phase).toBe("cancelling"); + expect(status.activeTool).toBeUndefined(); + }); + }); + + describe("statusText copy priority", () => { + it("shows 等待模型响应 with elapsed when no event has arrived yet", () => { + const status = createGenerationStatus(T0); + expect(statusText(status, T0 + 42_000, t)).toBe("等待模型响应 · 已运行 42s"); + }); + + it("shows 正在生成回复 after a delta", () => { + // Delta 1s before `now` so the idle branch (20s) must NOT win. + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "text_delta", delta: "hi" }, T0 + 41_000); + expect(statusText(status, T0 + 42_000, t)).toBe("正在生成回复 · 已运行 42s"); + }); + + it("shows 第 N 轮 · 正在执行 {tool} for a running tool with turn + 1", () => { + // Tool started 1s before `now` so the idle branch (20s) must NOT win. + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 1 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 41_000); + expect(statusText(status, T0 + 42_000, t)).toBe("第 2 轮 · 正在执行 执行 SQL · 已运行 42s"); + }); + + it("defaults the turn to 1 when a tool runs before any turn_start", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 41_000); + expect(statusText(status, T0 + 42_000, t)).toBe("第 1 轮 · 正在执行 执行 SQL · 已运行 42s"); + }); + + it("switches to the idle copy when the last event is older than 20s", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 1_000); + const now = T0 + 1_000 + STATUS_IDLE_THRESHOLD_MS + 1; + expect(statusText(status, now, t)).toBe("等待此步骤完成 · 最后活动 21s 前"); + }); + + it("idle copy appends the active tool when a tool is running", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + const now = T0 + 1_000 + STATUS_IDLE_THRESHOLD_MS + 1; + expect(statusText(status, now, t)).toBe("等待此步骤完成 · 最后活动 21s 前 · 正在执行 执行 SQL"); + }); + + it("MUST NOT hit the idle branch when lastEventAt is undefined, even past 20s of elapsed time", () => { + const status = createGenerationStatus(T0); + expect(statusText(status, T0 + 42_000, t)).toBe("等待模型响应 · 已运行 42s"); + }); + + it("shows the cancelling copy once the user requested stop", () => { + let status = createGenerationStatus(T0); + status = markCancelling(status, T0 + 5_000); + expect(statusText(status, T0 + 5_000, t)).toBe("正在取消…"); + }); + + it("returns an empty string for a finished status (line is hidden), never the 0s residue copy", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "text_delta", delta: "hi" }, T0 + 41_000); + status = applyStatusEvent(status, { type: "agent_end" }, T0 + 42_000); + expect(statusText(status, T0 + 42_000, t)).toBe(""); + }); + + it("a finished status never leaks the idle copy, even with a very old lastEventAt", () => { + // Regression for the "thinking…0s" residue: the finished guard must win over + // the idle branch (and the waitingModel fallthrough), or a completed reply + // could re-render "等待此步骤完成 · 最后活动 Ns 前". + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "agent_end" }, T0 + 42_000); + const now = T0 + 42_000 + STATUS_IDLE_THRESHOLD_MS + 1; + expect(statusText(status, now, t)).toBe(""); + }); + + it("rounds elapsed up to whole seconds", () => { + const status = createGenerationStatus(T0); + expect(statusText(status, T0 + 1, t)).toBe("等待模型响应 · 已运行 1s"); + expect(statusText(status, T0 + 1_000, t)).toBe("等待模型响应 · 已运行 1s"); + expect(statusText(status, T0 + 1_001, t)).toBe("等待模型响应 · 已运行 2s"); + }); + + it("idle seconds are rounded up as well", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 1_000); + const now = T0 + 1_000 + STATUS_IDLE_THRESHOLD_MS + 1; + expect(statusText(status, now, t)).toContain("最后活动 21s 前"); + }); + }); + + describe("liveAnnouncementText (screen-reader live region)", () => { + // The live region MUST NOT contain the ticking elapsed/idle numerals — a + // per-second re-announcement would spam screen-reader users. Assert the + // announced string never embeds a "\d+s" countdown. + const expectNoTickingSeconds = (announced: string) => { + expect(announced).not.toMatch(/\d+s/); + }; + + it("announces stable state without elapsed numerals (waiting model)", () => { + const status = createGenerationStatus(T0); + const announced = liveAnnouncementText(status, T0 + 42_000, t); + expect(announced).toBe("等待模型响应"); + expectNoTickingSeconds(announced); + }); + + it("announces generating without elapsed numerals", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "text_delta", delta: "hi" }, T0 + 41_000); + const announced = liveAnnouncementText(status, T0 + 42_000, t); + expect(announced).toBe("正在生成回复"); + expectNoTickingSeconds(announced); + }); + + it("announces the running tool with turn + 1, no elapsed numerals", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 1 }, T0 + 1_000); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 41_000); + const announced = liveAnnouncementText(status, T0 + 42_000, t); + expect(announced).toBe("第 2 轮 · 正在执行 执行 SQL"); + expectNoTickingSeconds(announced); + }); + + it("announces the idle cross once (waiting for step), still no idle seconds", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "turn_start", turn: 0 }, T0 + 1_000); + const now = T0 + 1_000 + STATUS_IDLE_THRESHOLD_MS + 1; + const announced = liveAnnouncementText(status, now, t); + expect(announced).toBe("等待此步骤完成"); + expectNoTickingSeconds(announced); + }); + + it("idle-with-tool announces the tool, no idle seconds", () => { + let status = createGenerationStatus(T0); + status = applyStatusEvent(status, { type: "tool_call_start", tool_call_id: "c1", tool_name: "execute_sql", args: {} }, T0 + 1_000); + const now = T0 + 1_000 + STATUS_IDLE_THRESHOLD_MS + 1; + const announced = liveAnnouncementText(status, now, t); + expect(announced).toBe("等待此步骤完成 · 正在执行 执行 SQL"); + expectNoTickingSeconds(announced); + }); + + it("announces cancelling when the user requested stop", () => { + let status = markCancelling(createGenerationStatus(T0), T0 + 5_000); + const announced = liveAnnouncementText(status, T0 + 5_000, t); + expect(announced).toBe("正在取消…"); + expectNoTickingSeconds(announced); + }); + + it("announces nothing for a finished status (line is hidden)", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "agent_end" }, T0 + 42_000); + const announced = liveAnnouncementText(status, T0 + 42_000, t); + expect(announced).toBe(""); + expectNoTickingSeconds(announced); + }); + }); + + describe("shouldShowLongRunningHint (60s threshold)", () => { + it("is hidden up to and at 60s, shown past 60s", () => { + const status = createGenerationStatus(T0); + expect(shouldShowLongRunningHint(status, T0 + STATUS_LONG_RUNNING_THRESHOLD_MS)).toBe(false); + expect(shouldShowLongRunningHint(status, T0 + STATUS_LONG_RUNNING_THRESHOLD_MS + 1)).toBe(true); + }); + + it("is never shown for a finished status, even past 60s", () => { + let status = applyStatusEvent(createGenerationStatus(T0), { type: "agent_end" }, T0 + STATUS_LONG_RUNNING_THRESHOLD_MS + 1); + expect(shouldShowLongRunningHint(status, T0 + STATUS_LONG_RUNNING_THRESHOLD_MS + 1)).toBe(false); + }); + }); + + describe("toolLabel", () => { + it("resolves known tools through the i18n map", () => { + expect(toolLabel("execute_sql", t)).toBe("执行 SQL"); + expect(toolLabel("list_tables", t)).toBe("列出数据表"); + }); + + it("falls back to the raw snake_case name for unknown tools", () => { + expect(toolLabel("mystery_tool", t)).toBe("mystery_tool"); + }); + }); + + describe("status ticker (createStatusTicker / nextStatusTickDelay)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("nextStatusTickDelay aligns to the next wall-clock second", () => { + expect(nextStatusTickDelay(1_000_000_000)).toBe(1000); // exactly on a boundary -> next second + expect(nextStatusTickDelay(1_000_000_123)).toBe(877); // 123ms in -> 877ms to the boundary + expect(nextStatusTickDelay(1_000_000_999)).toBe(1); // 1ms before the boundary + }); + + it("start() seeds immediately then ticks once per whole second (not once per frame)", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000_123); + const seen: number[] = []; + const ticker = createStatusTicker((now) => seen.push(now)); + + ticker.start(Date.now()); + expect(seen).toEqual([1_000_000_123]); // fresh timestamp seed at start + + // 876ms of elapsed time sits strictly before the next boundary -> no tick. + vi.advanceTimersByTime(876); + expect(seen).toHaveLength(1); + + // Crossing 1_000_001_000 fires exactly one boundary tick (not 60 frames). + vi.advanceTimersByTime(1); + expect(seen).toEqual([1_000_000_123, 1_000_001_000]); + + // A whole second later -> exactly one more tick, re-aligned to the boundary. + vi.advanceTimersByTime(1000); + expect(seen).toEqual([1_000_000_123, 1_000_001_000, 1_000_002_000]); + }); + + it("stop() cancels the pending tick and the ticker stays stopped", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000_123); + const seen: number[] = []; + const ticker = createStatusTicker((now) => seen.push(now)); + + ticker.start(Date.now()); + vi.advanceTimersByTime(1000); + const countAfterBoundary = seen.length; + expect(countAfterBoundary).toBe(2); + + ticker.stop(); + vi.advanceTimersByTime(10_000); + expect(seen.length).toBe(countAfterBoundary); + }); + + it("start() after stop() re-seeds from the new time and keeps ticking", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000_123); + const seen: number[] = []; + const ticker = createStatusTicker((now) => seen.push(now)); + + ticker.start(Date.now()); + vi.advanceTimersByTime(877); // boundary tick at 1_000_001_000 + ticker.stop(); + ticker.start(Date.now()); // re-seed at 1_000_001_000 + expect(seen).toEqual([1_000_000_123, 1_000_001_000, 1_000_001_000]); + + vi.advanceTimersByTime(1000); // next boundary at 1_000_002_000 + expect(seen).toEqual([1_000_000_123, 1_000_001_000, 1_000_001_000, 1_000_002_000]); + }); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiAgentStepPresentation.ts b/apps/desktop/src/lib/ai/aiAgentStepPresentation.ts index dc0c59b18d..7356c08fd6 100644 --- a/apps/desktop/src/lib/ai/aiAgentStepPresentation.ts +++ b/apps/desktop/src/lib/ai/aiAgentStepPresentation.ts @@ -18,6 +18,12 @@ export interface AiAgentStepItem { isError?: boolean; /** Structured explain plan data (for explain_query tool results) */ explainData?: unknown; + /** Wall-clock timestamp when the tool call started (stamped by tool_call_start) */ + startedAtMs?: number; + /** Wall-clock timestamp when the tool call finished (stamped by tool_call_end) */ + endedAtMs?: number; + /** Computed tool duration (endedAtMs - startedAtMs), present only when both stamps exist */ + durationMs?: number; } /** Backend fallback tool_call_id values that repeat across calls and must not be used as stable merge keys. */ @@ -46,9 +52,25 @@ export function upsertAgentStep(steps: AiAgentStepItem[], step: AiAgentStepItem) if (!merged.explainData && existing.explainData) merged.explainData = existing.explainData; if (!merged.titleKey && existing.titleKey) merged.titleKey = existing.titleKey; if (!merged.titleParams && existing.titleParams) merged.titleParams = existing.titleParams; + if (merged.startedAtMs === undefined && existing.startedAtMs !== undefined) merged.startedAtMs = existing.startedAtMs; + if (merged.endedAtMs !== undefined && merged.startedAtMs !== undefined) { + merged.durationMs = Math.max(0, merged.endedAtMs - merged.startedAtMs); + } steps.splice(idx, 1, merged); } +/** + * Format a tool duration for the step-card tail: sub-second → one decimal + * ("0.8s"); 1s–10s → one decimal ("1.2s"); ≥10s → integer ("12s"). + * Negative durations clamp to 0; callers hide the tail entirely when + * `durationMs` is absent. + */ +export function formatToolDurationMs(ms: number): string { + const safe = Math.max(0, ms); + if (safe >= 10_000) return `${Math.round(safe / 1000)}s`; + return `${(safe / 1000).toFixed(1)}s`; +} + export function buildAiAgentStepItems(plan: AiAgentPlan): AiAgentStepItem[] { return plan.steps.map(presentStep); } diff --git a/apps/desktop/src/lib/ai/aiGenerationStatus.ts b/apps/desktop/src/lib/ai/aiGenerationStatus.ts new file mode 100644 index 0000000000..67aaeee53e --- /dev/null +++ b/apps/desktop/src/lib/ai/aiGenerationStatus.ts @@ -0,0 +1,350 @@ +/** + * Pure state machine + copy helpers for the AI assistant's live generation-status + * line (Issue #6743, feature 1). + * + * The component keeps an `AiGenerationStatus` ref, feeds every `ai-agent-event` + * into `applyStatusEvent`, and runs a wall-clock whole-second ticker + * (`createStatusTicker`) that recomputes `now` for + * `statusText`/`shouldShowLongRunningHint`. Keeping the reducer, the copy + * selection, and the ticker pure (all inject `now`; i18n `t` is injected into + * `statusText`) makes the whole feature unit-testable without a DOM harness, + * matching the existing pattern of `aiConversationLifecycle.ts` / + * `aiGenerationGuard.ts`. + * + * Phase determination is EVENT-DRIVEN, never mode-based: Ask mode also emits + * `turn_start` and can invoke read-only tools, so `running_tool` is decided purely + * by `tool_call_start`/`tool_call_end`, not by `assistantMode`. + */ +import type { AgentEvent } from "@/lib/backend/tauri"; + +export interface AiGenerationStatus { + /** When `send()` set `isGenerating=true`. Elapsed time is derived from this. */ + startedAt: number; + /** Most recent event time; `undefined` means no agent event has arrived yet. */ + lastEventAt?: number; + /** 0-based turn from the latest `turn_start`; display as `turn + 1`. */ + turn?: number; + /** Active tool between `tool_call_start` and the matching `tool_call_end`. */ + activeTool?: { name: string; startedAt: number }; + /** + * All outstanding tools for the current turn. The backend emits every start + * before running read-only tools in parallel, so `activeTool` alone cannot + * tell whether an earlier completion leaves a later tool still running. + * `activeTool` remains the newest entry for the compact single-tool UI. + */ + activeTools?: Array<{ toolCallId: string; name: string; startedAt: number }>; + /** + * The reply stream is fully consumed but the run is not yet confirmed + * successful (entered on `response_complete`). The component hides the line + * just like `finished`, but keeps listening for the real terminal `agent_end` + * (success) / `error` (failure). + */ + phase: "preparing" | "waiting_model" | "generating" | "running_tool" | "cancelling" | "finalizing" | "finished"; + /** When the user explicitly requested cancellation (Stop button). */ + cancelledAt?: number; +} + +/** Events silent longer than this switch the copy to "等待此步骤完成 · 最后活动 Ns 前". */ +export const STATUS_IDLE_THRESHOLD_MS = 20_000; + +/** Generations running longer than this get the gentle "可继续等待或停止" hint. */ +export const STATUS_LONG_RUNNING_THRESHOLD_MS = 60_000; + +/** vue-i18n `t` surface consumed by `statusText` / `toolLabel` (injected for testability). */ +export type AiStatusTranslate = { + (key: string): string; + (key: string, named: Record): string; +}; + +/** i18n key per agent tool name; unknown tools fall back to the raw snake_case name. */ +export const STATUS_TOOL_LABEL_KEYS: Record = { + execute_query: "ai.status.toolLabels.executeQuery", + execute_sql: "ai.status.toolLabels.executeSql", + list_tables: "ai.status.toolLabels.listTables", + get_columns: "ai.status.toolLabels.getColumns", + get_current_time: "ai.status.toolLabels.getCurrentTime", + explain_query: "ai.status.toolLabels.explainQuery", + get_sample_data: "ai.status.toolLabels.getSampleData", + list_collections: "ai.status.toolLabels.listCollections", + browse_collection: "ai.status.toolLabels.browseCollection", +}; + +/** Initial status at `send()` time: `phase=preparing`, `startedAt=now`. */ +export function createGenerationStatus(now: number): AiGenerationStatus { + return { startedAt: now, phase: "preparing" }; +} + +/** + * Event-driven reducer. Returns a NEW status object (never mutates the input). + * + * - Any event refreshes `lastEventAt`. + * - `turn_start` records the 0-based turn. + * - `tool_call_start` enters `running_tool` and tracks the tool by call ID. + * - `tool_call_end` removes only its matching tool. The phase falls back to + * `generating` only after the last outstanding tool has completed. + * - `text_delta` / `reasoning_delta` with no active tool enter `generating`. + * - `write_sql_confirmation_required` / `production_write_blocked` / + * `context_compacted` only refresh `lastEventAt`, they never change the phase. + * - `agent_end` / `error` are terminal: they enter `finished` (preserving + * `startedAt`/`turn`, clearing `activeTool`) so the component can hide the line + * the instant the reply completes instead of resetting the counter to 0. + * - `response_complete` (non-terminal) enters `finalizing`: the reply stream is + * fully consumed but the process is not yet confirmed successful. It hides the + * line like `finished`, but only `agent_end` / `error` may advance the phase + * from `finalizing` — a late `text_delta` must not flip it back to `generating`. + * - Once the user requested cancellation (`cancelling`) OR the generation has + * terminated (`finished`) OR the stream is `finalizing`, later non-terminal + * agent events must not overwrite the phase — only the component ticker keeps + * running until `send()`'s `finally`/abandon path resets the per-request status. + */ +export function applyStatusEvent(status: AiGenerationStatus, event: AgentEvent, now: number): AiGenerationStatus { + if ( + status.phase === "cancelling" || + status.phase === "finished" || + // `finalizing` blocks everything except the real terminal events. + (status.phase === "finalizing" && event.type !== "agent_end" && event.type !== "error") + ) { + return { ...status, lastEventAt: now }; + } + const next: AiGenerationStatus = { ...status, lastEventAt: now }; + switch (event.type) { + case "turn_start": + next.turn = event.turn; + break; + case "tool_call_start": + // `agent_loop` emits all starts before concurrently executing read-only + // tools. Keep the latest tool as the displayed one, while retaining the + // earlier calls so their completion cannot clear this status prematurely. + next.activeTools = (next.activeTools ?? []).filter((tool) => tool.toolCallId !== event.tool_call_id); + next.activeTools.push({ toolCallId: event.tool_call_id, name: event.tool_name, startedAt: now }); + next.phase = "running_tool"; + next.activeTool = { name: event.tool_name, startedAt: now }; + break; + case "tool_call_end": { + const activeTools = next.activeTools; + if (!activeTools) { + // Preserve the safe fallback for streams that deliver an end without a + // corresponding start (older or incomplete provider event streams). + next.activeTool = undefined; + next.phase = "generating"; + break; + } + const remainingTools = activeTools.filter((tool) => tool.toolCallId !== event.tool_call_id); + next.activeTools = remainingTools.length ? remainingTools : undefined; + const newestActiveTool = remainingTools[remainingTools.length - 1]; + if (newestActiveTool) { + next.activeTool = { name: newestActiveTool.name, startedAt: newestActiveTool.startedAt }; + next.phase = "running_tool"; + } else { + next.activeTool = undefined; + next.phase = "generating"; + } + break; + } + case "text_delta": + case "reasoning_delta": + if (!next.activeTool) next.phase = "generating"; + break; + case "write_sql_confirmation_required": + case "production_write_blocked": + case "context_compacted": + // Only refresh lastEventAt above; do NOT change phase. + break; + case "agent_end": + case "error": + // Terminal: preserve startedAt (the elapsed counter must not reset to 0), + // clear the active tool, and let the component hide the line via `finished`. + next.phase = "finished"; + next.activeTool = undefined; + next.activeTools = undefined; + break; + case "response_complete": + // Non-terminal stream-complete marker: the reply is fully consumed but the + // run is not yet confirmed successful (the CLI may still exit non-zero or + // hang after closing stdout). Hide the line like `finished`, but keep the + // listener alive for the real `agent_end` / `error`. + next.phase = "finalizing"; + next.activeTool = undefined; + next.activeTools = undefined; + break; + default: + break; + } + return next; +} + +/** User explicitly requested stop — show the cancelling phase until the generation ends. */ +export function markCancelling(status: AiGenerationStatus, now: number): AiGenerationStatus { + // A generation that already reached the terminal `finished` state (reply + // complete, send()'s finally still settling) or the `finalizing` state (reply + // stream fully consumed, process exit pending) must not be flipped back into + // "正在取消…" — the reply is already done, the line stays hidden. + if (status.phase === "finished" || status.phase === "finalizing") return status; + return { ...status, phase: "cancelling", cancelledAt: now, lastEventAt: now }; +} + +/** Resolve a snake_case tool name through the i18n label map, falling back to the raw name. */ +export function toolLabel(toolName: string, t: AiStatusTranslate): string { + const key = STATUS_TOOL_LABEL_KEYS[toolName]; + return key ? t(key) : toolName; +} + +/** Round a duration up to whole seconds, never below 0 (guards a stale timer -1s). */ +function secondsUp(milliseconds: number): number { + return Math.max(0, Math.ceil(milliseconds / 1000)); +} + +/** + * Status-line copy, highest priority first: + * + * 1. Idle timeout (`lastEventAt` defined AND `now - lastEventAt > 20s`) → + * "等待此步骤完成 · 最后活动 {idle}s 前" (+ " · 正在执行 {tool}" when a tool is active). + * 2. `running_tool` with an active tool → "第 {turn+1} 轮 · 正在执行 {tool} · 已运行 {elapsed}s". + * 3. `generating` (a delta has arrived) → "正在生成回复 · 已运行 {elapsed}s". + * 4. `lastEventAt === undefined` (no events yet — e.g. a slow CLI first token) → + * "等待模型响应 · 已运行 {elapsed}s". Separate branch: it must NOT hit the idle branch. + * + * Cancelling is handled first — once the user clicks Stop the line reads "正在取消…". + * `finished` is terminal and the component hides the line, so it returns an empty + * string here (never the waitingModel/idle copy) to keep the pure function total. + * `finalizing` (reply stream consumed, process exit pending) also returns an + * empty string — the line is hidden while the real `agent_end`/`error` is still + * pending. + * The >60s gentle hint is NOT part of this string; it renders next to the Stop + * button via `shouldShowLongRunningHint`. + */ +export function statusText(status: AiGenerationStatus, now: number, t: AiStatusTranslate): string { + // `finished`/`finalizing` must win over BOTH the idle branch and the + // waitingModel fallthrough, or a completed reply could re-render + // "等待此步骤完成 · 最后活动 Ns 前"/"0s". + if (status.phase === "finished" || status.phase === "finalizing") return ""; + const elapsed = secondsUp(now - status.startedAt); + if (status.phase === "cancelling") { + return t("ai.status.cancelling"); + } + + if (status.lastEventAt !== undefined && now - status.lastEventAt > STATUS_IDLE_THRESHOLD_MS) { + const idle = secondsUp(now - status.lastEventAt); + if (status.activeTool) { + return t("ai.status.idleWithTool", { idle, tool: toolLabel(status.activeTool.name, t) }); + } + return t("ai.status.idle", { idle }); + } + + if (status.phase === "running_tool" && status.activeTool) { + return t("ai.status.runningTool", { + turn: status.turn !== undefined ? status.turn + 1 : 1, + tool: toolLabel(status.activeTool.name, t), + elapsed, + }); + } + + if (status.phase === "generating") { + return t("ai.status.generating", { elapsed }); + } + + return t("ai.status.waitingModel", { elapsed }); +} + +/** + * Screen-reader announcement for the status line (Issue #6743 feature 1, a11y). + * + * Rendered inside a `role="status"` (`aria-live="polite"` + `aria-atomic`) live + * region. Deliberately EXCLUDES the ticking elapsed/idle numerals that + * `statusText` embeds — a live region whose content changed once per second + * would re-announce the running timer on every tick and spam screen-reader + * users. Only discrete state changes are announced: phase transitions, tool + * start/end, turn, and crossing the 20s idle threshold (the idle cross happens + * once and the announced string stays stable until the next event). + */ +export function liveAnnouncementText(status: AiGenerationStatus, now: number, t: AiStatusTranslate): string { + // A completed reply is silent for screen readers too — never re-announce the + // waitingModel/idle copy under a finished/finalizing generation. + if (status.phase === "finished" || status.phase === "finalizing") return ""; + if (status.phase === "cancelling") return t("ai.status.cancelling"); + + const idle = status.lastEventAt !== undefined && now - status.lastEventAt > STATUS_IDLE_THRESHOLD_MS; + if (idle) { + return status.activeTool ? `${t("ai.status.idleLive")} ${t("ai.status.runningToolAction")} ${toolLabel(status.activeTool.name, t)}` : t("ai.status.idleLive"); + } + + if (status.phase === "running_tool" && status.activeTool) { + const turn = status.turn; + const badge = turn !== undefined ? t("ai.status.turnBadge", { turn: turn + 1 }) : ""; + return [badge, t("ai.status.runningToolAction"), toolLabel(status.activeTool.name, t)].filter(Boolean).join(" "); + } + + if (status.phase === "generating") return t("ai.status.generatingLive"); + + return t("ai.status.waitingModelLive"); +} + +/** Whether the gentle "响应时间较长,可继续等待或停止" hint should appear next to Stop. */ +export function shouldShowLongRunningHint(status: AiGenerationStatus, now: number): boolean { + return status.phase !== "finished" && status.phase !== "finalizing" && now - status.startedAt > STATUS_LONG_RUNNING_THRESHOLD_MS; +} + +/** + * Delay (ms) until the next real wall-clock second boundary. Used to align the + * status ticker to whole-second boundaries so the displayed elapsed/idle rolls + * +1s exactly when the wall clock does — a fixed `setInterval(1000)` drifts and + * skips values when a late callback shifts the phase. + */ +export function nextStatusTickDelay(now: number): number { + return 1000 - (now % 1000); +} + +/** + * Cancellable whole-second ticker for the live generation-status line. + * + * Replaces a per-frame `requestAnimationFrame` loop that rescheduled 60+ times + * per second while only updating displayed state once per second. This ticker + * runs ONE `setTimeout` per whole-second boundary: it schedules the next tick to + * the next real wall-clock second (`nextStatusTickDelay`), so a delayed tick + * re-aligns instead of accumulating drift. Being wall-clock based it also keeps + * ticking while the document is hidden/occluded (rAF pauses then), so a long + * request's idle/elapsed display stays live in the background. + * + * `start` seeds the callback immediately with `now` (so the line shows a fresh + * timestamp) and schedules the next boundary tick. `stop` cancels a pending + * tick and clears the running flag; a tick callback already queued when `stop` + * runs bails without rescheduling (the `running` guard), so a stopped ticker + * stays stopped. `start` is idempotent (re-starts from the given `now`). + */ +export interface StatusTicker { + start(now: number): void; + stop(): void; +} + +export function createStatusTicker(onTick: (now: number) => void): StatusTicker { + let timer: ReturnType | null = null; + let running = false; + + function tick() { + timer = null; + // A tick queued before stop() may still fire once after it — bail without + // rescheduling so a stopped ticker stays stopped. + if (!running) return; + const now = Date.now(); + onTick(now); + timer = setTimeout(tick, nextStatusTickDelay(now)); + } + + function stop() { + running = false; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + } + + return { + start(now: number) { + stop(); + running = true; + onTick(now); + timer = setTimeout(tick, nextStatusTickDelay(now)); + }, + stop, + }; +} diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 40367c33e1..9ea3c95dfc 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -484,6 +484,16 @@ export type AgentEvent = is_error: boolean; } | { type: "turn_end"; turn: number } + | { + /** + * The reply stream is fully consumed but the run is NOT yet confirmed + * successful — the CLI process may still exit non-zero or hang after + * closing stdout. Non-terminal: the UI may stop the reply animation on + * it, but must keep listening for the real `agent_end` (success) / + * `error` (failure). + */ + type: "response_complete"; + } | { type: "agent_end"; input_tokens?: number; output_tokens?: number } | { type: "context_compacted"; diff --git a/crates/dbx-core/src/agent_events.rs b/crates/dbx-core/src/agent_events.rs index 41b9b3aa08..8d7cb64343 100644 --- a/crates/dbx-core/src/agent_events.rs +++ b/crates/dbx-core/src/agent_events.rs @@ -22,7 +22,12 @@ pub enum AgentEvent { ToolCallEnd { tool_call_id: String, tool_name: String, result: serde_json::Value, is_error: bool }, /// A turn in the agent loop has ended. TurnEnd { turn: u32 }, - /// The agent loop has finished. + /// The reply stream is fully consumed, but the run is NOT yet confirmed + /// successful (the CLI process may still exit non-zero or hang after closing + /// stdout). Non-terminal: the frontend may stop the reply animation on it, + /// but must keep listening for the real `AgentEnd` / `Error`. + ResponseComplete, + /// The agent loop has finished successfully. AgentEnd { input_tokens: Option, output_tokens: Option }, /// Context was compacted to stay within context window limits. ContextCompacted { diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index 96f2231da2..7d4fefab4c 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -1000,6 +1000,23 @@ fn grok_usage_tokens(usage: &Value) -> Option { (input > 0 || output > 0).then_some(TokenUsage { input_tokens: input, output_tokens: output }) } +/// Stream a CLI agent's JSONL output to `on_event`, then return the accumulated +/// final text. +/// +/// `AgentEnd` is emitted ONLY after the process is confirmed successful: the CLI +/// is reaped via `child.wait()` and the run must have no terminal error, no +/// cancellation, and a zero exit status. Both the CLI's own `end` marker and the +/// synthesized `AgentEnd` (used when the CLI emits none) are gated behind that +/// confirmed exit — a CLI that closes stdout then hangs or exits non-zero must +/// never be shown to the frontend as a completed reply. +/// +/// To still let the frontend stop the reply animation promptly, a NON-terminal +/// `ResponseComplete` is dispatched as soon as stdout is fully consumed (before +/// `child.wait()` / the stderr drain, which can take seconds on some CLIs). The +/// frontend may hide the status line on `ResponseComplete` but must keep +/// listening for the real `AgentEnd` (success) / `Error` (failure). Cancellation +/// / read-error paths skip `ResponseComplete` so the frontend cancel flow keeps +/// its `cancelling` phase until `finally`. pub async fn run_cli_jsonl_agent( spec: CliAgentProcessSpec, cancelled: &Notify, @@ -1039,7 +1056,7 @@ pub async fn run_cli_jsonl_agent( }); let mut final_text = String::new(); - let mut saw_agent_end = false; + let mut pending_agent_end: Option = None; let mut total_usage = TokenUsage::default(); let mut terminal_error: Option = None; @@ -1063,8 +1080,17 @@ pub async fn run_cli_jsonl_agent( total_usage.add(&usage); } for event in parsed.events { + // A CLI-emitted `end` is a stream marker, not a confirmed + // success: the process may still exit non-zero (or hang) + // after closing stdout. Buffer it and re-emit only after + // child.wait() confirms the exit status (see below). + // Dialects that emit one `end` per tool result (e.g. Cursor) + // collapse into the single confirmed-success AgentEnd here — + // forwarding them inline would show the run as complete at + // the first tool result. if matches!(event, AgentEvent::AgentEnd { .. }) { - saw_agent_end = true; + pending_agent_end = Some(event); + continue; } on_event(event); } @@ -1082,8 +1108,34 @@ pub async fn run_cli_jsonl_agent( } } - let status = child.wait().await.map_err(|e| format!("CLI agent wait failed: {e}"))?; - let stderr = stderr_task.await.unwrap_or_default(); + // stdout is fully consumed. Emit a NON-terminal marker so the frontend can + // stop the reply animation (hide the status line) without treating the run + // as finished — the process is not confirmed successful until child.wait() + // returns a zero exit status below. Skipped on error/cancel paths so the + // frontend keeps its `cancelling`/`error` handling until `finally`. + if terminal_error.is_none() { + on_event(AgentEvent::ResponseComplete); + } + + // The read loop above is gone, so this wait is the last chance to observe a + // cancel. Race the (potentially slow) post-EOF reap and stderr drain against + // `cancelled` so Stop still kills and reaps a CLI that closes stdout and + // then hangs. On cancel we kill() then wait() (kill-and-reap) so the child + // leaves no zombie behind, then return the cancel error. `stderr_task` is + // dropped on that branch — the pipe closes once the child is terminated, so + // the reader completes on its own (no leak, no hang). + let (status, stderr) = tokio::select! { + status = child.wait() => { + let status = status.map_err(|e| format!("CLI agent wait failed: {e}"))?; + let stderr = stderr_task.await.unwrap_or_default(); + (status, stderr) + } + _ = cancelled.notified() => { + let _ = child.kill().await; + let _ = child.wait().await; // reap; kill() then wait() prevents a zombie + return Err("Agent loop cancelled".to_string()); + } + }; if let Some(error) = terminal_error { return Err(error); @@ -1093,7 +1145,19 @@ pub async fn run_cli_jsonl_agent( return Err((spec.classify_run_error)(&stderr)); } - if !saw_agent_end { + // Confirmed success. Forward the CLI's own `end` (backfilling usage from the + // accumulated totals when it carried none), else synthesize an `AgentEnd`. + if let Some(agent_end) = pending_agent_end { + if let AgentEvent::AgentEnd { input_tokens, output_tokens } = agent_end { + let agent_end = AgentEvent::AgentEnd { + input_tokens: input_tokens + .or_else(|| (total_usage.input_tokens > 0).then_some(total_usage.input_tokens)), + output_tokens: output_tokens + .or_else(|| (total_usage.output_tokens > 0).then_some(total_usage.output_tokens)), + }; + on_event(agent_end); + } + } else { on_event(AgentEvent::AgentEnd { input_tokens: (total_usage.input_tokens > 0).then_some(total_usage.input_tokens), output_tokens: (total_usage.output_tokens > 0).then_some(total_usage.output_tokens), @@ -1286,6 +1350,166 @@ mod tests { .any(|event| { matches!(event, AgentEvent::AgentEnd { input_tokens: Some(13), output_tokens: Some(6) }) })); } + #[tokio::test] + async fn jsonl_agent_never_emits_agent_end_when_process_fails_after_stdout_eof() { + // The CLI emits its final JSONL line, closes stdout (so the read loop + // sees EOF), lingers for a second, then exits with status 1. AgentEnd + // must NOT be emitted before the failure is determined — the frontend + // must not be shown a completed reply for a failed run. `ResponseComplete` + // (non-terminal) is still dispatched early so the reply animation stops. + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + concat!( + "printf '%s\\n' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":10,\"output\":2}}}' ", + "'{\"type\":\"text\",\"part\":{\"text\":\"hello\"}}' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":4}}}'; ", + "exec 1>&-; sleep 1; exit 1", + ) + .to_string(), + ], + }, + env: Vec::new(), + env_remove: Vec::new(), + current_dir: None, + stdin: None, + dialect: CliAgentJsonlDialect::OpenCodeRun, + classify_spawn_error, + classify_run_error, + }; + let events = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&events); + let (probe_tx, mut probe_rx) = tokio::sync::watch::channel(0u8); + let cancelled = Arc::new(Notify::new()); + let runner_cancelled = Arc::clone(&cancelled); + + let runner = tokio::spawn(async move { + run_cli_jsonl_agent(spec, runner_cancelled.as_ref(), move |event| { + if matches!(event, AgentEvent::ResponseComplete) { + let _ = probe_tx.send(1); + } + if matches!(event, AgentEvent::AgentEnd { .. }) { + let _ = probe_tx.send(2); + } + captured.lock().unwrap().push(event); + }) + .await + }); + + // ResponseComplete must arrive as soon as stdout is consumed — well + // before the lingering process finishes its 1s shutdown. + timeout(Duration::from_secs(1), probe_rx.wait_for(|seen| *seen >= 1)) + .await + .expect("ResponseComplete should be emitted while the process still runs") + .expect("probe watch channel closed before ResponseComplete was emitted"); + + // While the process is still alive (sleeping before its exit 1), AgentEnd + // must NOT have been emitted yet — the failure is not yet confirmed. + assert_eq!(*probe_rx.borrow(), 1, "AgentEnd must not be emitted before the exit status is known"); + + let result = timeout(Duration::from_secs(5), runner) + .await + .expect("runner should return once the failing process exits") + .expect("runner task should not panic"); + assert!(result.is_err(), "run must fail for a non-zero exit"); + + let events = events.lock().unwrap(); + assert_eq!( + events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), + 0, + "AgentEnd must never be emitted for a failed run" + ); + assert!( + events.iter().any(|event| matches!(event, AgentEvent::ResponseComplete)), + "ResponseComplete should still be emitted for the failed run" + ); + } + + #[tokio::test] + async fn jsonl_agent_emits_response_complete_early_and_agent_end_only_after_confirmed_exit() { + // The CLI emits its final JSONL line, closes stdout (so the read loop + // sees EOF), lingers for a second, then exits with status 0. The + // non-terminal ResponseComplete must be dispatched as soon as stdout is + // consumed (before the process reap), while AgentEnd must wait for the + // confirmed-success exit and be the final event. + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + concat!( + "printf '%s\\n' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":10,\"output\":2}}}' ", + "'{\"type\":\"text\",\"part\":{\"text\":\"hello\"}}' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":4}}}'; ", + "exec 1>&-; sleep 1", + ) + .to_string(), + ], + }, + env: Vec::new(), + env_remove: Vec::new(), + current_dir: None, + stdin: None, + dialect: CliAgentJsonlDialect::OpenCodeRun, + classify_spawn_error, + classify_run_error, + }; + let events = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&events); + let (probe_tx, mut probe_rx) = tokio::sync::watch::channel(0u8); + let cancelled = Arc::new(Notify::new()); + let runner_cancelled = Arc::clone(&cancelled); + + let runner = tokio::spawn(async move { + run_cli_jsonl_agent(spec, runner_cancelled.as_ref(), move |event| { + if matches!(event, AgentEvent::ResponseComplete) { + let _ = probe_tx.send(1); + } + if matches!(event, AgentEvent::AgentEnd { .. }) { + let _ = probe_tx.send(2); + } + captured.lock().unwrap().push(event); + }) + .await + }); + + // ResponseComplete must arrive as soon as the CLI output is consumed — + // well before the lingering process finishes its 1s shutdown. + timeout(Duration::from_secs(1), probe_rx.wait_for(|seen| *seen >= 1)) + .await + .expect("ResponseComplete should be emitted while the process still runs") + .expect("probe watch channel closed before ResponseComplete was emitted"); + + // While the process is still alive, AgentEnd must not have been emitted yet. + assert_eq!(*probe_rx.borrow(), 1, "AgentEnd must not be emitted before the exit status is known"); + + let result = timeout(Duration::from_secs(5), runner) + .await + .expect("runner should return once the lingering process exits") + .expect("runner task should not panic"); + assert_eq!(result.unwrap(), "hello"); + + let events = events.lock().unwrap(); + assert_eq!(events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), 1); + assert!(events + .iter() + .any(|event| { matches!(event, AgentEvent::AgentEnd { input_tokens: Some(13), output_tokens: Some(6) }) })); + let response_complete_at = events + .iter() + .position(|event| matches!(event, AgentEvent::ResponseComplete)) + .expect("ResponseComplete should have been emitted"); + let agent_end_at = events + .iter() + .position(|event| matches!(event, AgentEvent::AgentEnd { .. })) + .expect("AgentEnd should have been emitted after confirmed success"); + assert!(response_complete_at < agent_end_at, "ResponseComplete must precede AgentEnd"); + assert!(matches!(events.last(), Some(AgentEvent::AgentEnd { .. })), "AgentEnd should be the terminal event"); + } + #[tokio::test] async fn jsonl_error_kills_and_waits_for_child() { let pid_file = std::env::temp_dir().join(format!( @@ -1362,4 +1586,81 @@ mod tests { assert!(!process_is_alive(pid.trim())); let _ = std::fs::remove_file(pid_file); } + + #[tokio::test] + async fn jsonl_eof_cancel_kills_and_reaps_hanging_child() { + // The CLI emits a valid JSONL line, closes stdout (EOF -> ResponseComplete), + // then hangs before exiting. A cancel fired after EOF must race the + // post-EOF child.wait(): the child is killed AND reaped (no zombie) and + // the run returns promptly with the cancel error. No AgentEnd may be + // emitted on the cancel path. + let pid_file = std::env::temp_dir().join(format!( + "dbx-cli-agent-eof-cancel-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + let script = format!( + "echo $$ > {}; printf '%s\\n' '{{\"type\":\"text\",\"part\":{{\"text\":\"hello\"}}}}'; exec 1>&-; exec sleep 30", + pid_file.display() + ); + + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] }, + env: Vec::new(), + env_remove: Vec::new(), + current_dir: None, + stdin: None, + dialect: CliAgentJsonlDialect::OpenCodeRun, + classify_spawn_error, + classify_run_error, + }; + let events = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&events); + let (probe_tx, mut probe_rx) = tokio::sync::watch::channel(0u8); + let cancelled = Arc::new(Notify::new()); + let runner_cancelled = Arc::clone(&cancelled); + + let runner = tokio::spawn(async move { + run_cli_jsonl_agent(spec, runner_cancelled.as_ref(), move |event| { + if matches!(event, AgentEvent::ResponseComplete) { + let _ = probe_tx.send(1); + } + if matches!(event, AgentEvent::AgentEnd { .. }) { + let _ = probe_tx.send(2); + } + captured.lock().unwrap().push(event); + }) + .await + }); + + // ResponseComplete must arrive as soon as stdout is consumed — the child + // is still hanging at that point, before the post-EOF wait. + timeout(Duration::from_secs(1), probe_rx.wait_for(|seen| *seen >= 1)) + .await + .expect("ResponseComplete should be emitted while the process still runs") + .expect("probe watch channel closed before ResponseComplete was emitted"); + + assert_eq!(*probe_rx.borrow(), 1, "AgentEnd must not be emitted before the exit status is known"); + let pid = std::fs::read_to_string(&pid_file).expect("child pid should be captured"); + assert!(process_is_alive(pid.trim()), "hanging child should still be alive after ResponseComplete"); + + cancelled.notify_waiters(); + + let result = timeout(Duration::from_secs(3), runner) + .await + .expect("runner should return promptly after post-EOF cancellation") + .expect("runner task should not panic"); + assert_eq!(result.unwrap_err(), "Agent loop cancelled"); + + sleep(Duration::from_millis(100)).await; + assert!(!process_is_alive(pid.trim()), "hanging child should be killed and reaped"); + let _ = std::fs::remove_file(pid_file); + + let events = events.lock().unwrap(); + assert_eq!( + events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), + 0, + "AgentEnd must never be emitted on the cancel path" + ); + } } diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index 16b0df5df4..972d5ca285 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -127,9 +127,12 @@ test("DataGrid exposes persistent result toolbar slots", () => { test("table-data toolbar refresh keeps page size independent from SQL editor settings", () => { const dataGrid = source(dataGridPath); - assert.match(dataGrid, /preferredDataGridPageSize\(settingsStore\.editorSettings, pageSizePreference\.value, props\.pageLimit\)/); - assert.match(dataGrid, /if \(pageSizePreference\.value !== "results"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); - assert.match(dataGrid, /dataGridPageSizeSettingsPatch\(pageSizePreference\.value, normalizedSize\)/); + // Table-data grids resolve page size through the table-open preference + // (pageLimit ?? tableOpenPageLimit(tableOpenPageSize)) instead of the SQL editor pageSize. + assert.match(dataGrid, /pageSizePreference = computed\(\(\) => resolveDataGridPageSizePreference\(props\.context, props\.pageSizePreference\)\)/); + assert.match(dataGrid, /pageSize = ref\(preferredDataGridPageSize\(settingsStore\.editorSettings, pageSizePreference\.value, props\.pageLimit\)\)/); + assert.match(dataGrid, /watch\([\s\S]*\(\) => settingsStore\.editorSettings\.pageSize,[\s\S]*if \(pageSizePreference\.value !== "results"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); + assert.match(dataGrid, /settingsStore\.updateEditorSettings\(dataGridPageSizeSettingsPatch\(pageSizePreference\.value, normalizedSize\)\)/); assert.match(dataGrid, /const resetToFirstPage = hasPendingConditionInputs\(\);/); assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, resetToFirstPage \? 0 : \(currentPage\.value - 1\) \* pageSize\.value, "refresh"\)/); });