feat(ai): surface live AI generation status with smooth timing

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-24 20:28:12 +08:00
1 parent 697a8efe6e
commit ff31f44b42
18 files changed
+1733 -25

No files matched your search

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem } from "@/lib/ai/aiAgentStepPresentation";
const startStep: AiAgentStepItem = {
key: "tool-call-1",
labelKey: "ai.agentSteps.callingTool",
tone: "active",
toolName: "list_tables",
toolArgs: { sql: "SHOW TABLES" },
startedAtMs: 1_000,
};
describe("aiAgentStepPresentation", () => {
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");
});
});
});
@@ -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<string, unknown>) => {
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]);
});
});
});
@@ -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);
}
@@ -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, unknown>): string;
};
/** i18n key per agent tool name; unknown tools fall back to the raw snake_case name. */
export const STATUS_TOOL_LABEL_KEYS: Record<string, string> = {
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<typeof setTimeout> | 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,
};
}
+10
View File
@@ -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";