mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(ai): surface live AI generation status with smooth timing
This commit is contained in:
@@ -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<AiGenerationStatus>(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<string, unknown>,
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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) {
|
||||
<div v-if="msg.agentSteps?.length" class="mb-2 space-y-1">
|
||||
<div v-for="step in msg.agentSteps" :key="step.key" class="rounded border text-[10px]" :class="agentStepClass(step.tone)">
|
||||
<button class="flex w-full items-center gap-1 px-2 py-1.5 text-left" @click="step.toolResult || step.toolArgs?.sql ? toggleStep(step.key) : undefined">
|
||||
<component :is="agentStepIcon(step.tone)" class="h-3 w-3 shrink-0" />
|
||||
<Loader2 v-if="step.tone === 'active' && step.toolName" class="h-3 w-3 shrink-0 animate-spin" />
|
||||
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="font-medium">{{ t(step.labelKey) }}</span>
|
||||
<span v-if="step.toolName" class="text-muted-foreground">: {{ step.toolName }}</span>
|
||||
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="ml-auto h-3 w-3 shrink-0 transition-transform duration-150" :class="{ 'rotate-90': expandedSteps.has(step.key) }" />
|
||||
<template v-if="step.tone === 'active' && step.toolName">
|
||||
<span class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>{{ t("ai.agentSteps.executing") }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="step.durationMs !== undefined" class="ml-auto shrink-0 tabular-nums" :class="step.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-chart-2'">{{ formatToolDurationMs(step.durationMs) }}</span>
|
||||
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="h-3 w-3 shrink-0 transition-transform duration-150" :class="[{ 'rotate-90': expandedSteps.has(step.key) }, !agentStepHasTail(step) ? 'ml-auto' : '']" />
|
||||
</button>
|
||||
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
|
||||
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
|
||||
@@ -3407,9 +3529,39 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isWaitingForFirstDelta" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{{ t("ai.thinking") }}</span>
|
||||
<!-- Live generation-status line (Issue #6743 feature 1). Replaces the old
|
||||
"Thinking..." placeholder and covers the WHOLE generation period
|
||||
(`v-if="isGenerating"`), not just the wait for the first token. The
|
||||
`phase !== 'finished'` guard hides it the instant agent_end/error
|
||||
arrives — before isGenerating clears — so a completed reply never
|
||||
shows a lingering "等待模型响应 · 已运行 0s". `finalizing` (the
|
||||
non-terminal `response_complete`) hides the line the same way, while
|
||||
the listener stays alive for the real agent_end/error. -->
|
||||
<div v-if="isGenerating && generationStatus.phase !== 'finished' && generationStatus.phase !== 'finalizing'" class="flex min-w-0 items-center gap-[7px] text-xs text-muted-foreground" data-ai-generation-status>
|
||||
<!-- Screen-reader live region: announces discrete execution-state changes
|
||||
(phase / tool / turn / idle crossing) only, never the per-second
|
||||
elapsed numerals — see `liveAnnouncementText`. -->
|
||||
<span class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ statusLiveAnnouncement }}</span>
|
||||
<Loader2 v-if="!generationStatusIdle" class="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<Hourglass v-else class="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<!-- Idle-with-tool copy MUST win over the running-tool layout: PRD copy
|
||||
priority 1 (idle >20s, "等待此步骤完成 · 最后活动 Ns 前 · 正在执行 {tool}")
|
||||
outranks priority 2 ("第 N 轮 · 正在执行 {tool} · 已运行 Ns"), matching
|
||||
the pure `statusText()` branch order. Exclude the cancelling phase so
|
||||
"正在取消…" (checked first by `statusText`) is never masked by the idle
|
||||
copy while the user is stopping a long-running tool. -->
|
||||
<template v-if="generationStatusIdle && generationStatus.activeTool && generationStatus.phase !== 'cancelling'">
|
||||
<span class="whitespace-nowrap tabular-nums">{{ t("ai.status.idle", { idle: statusIdleSeconds }) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t("ai.status.runningToolAction") }}</span>
|
||||
<span class="whitespace-nowrap rounded-[5px] border border-chart-2/30 bg-chart-2/12 px-1.5 py-px font-mono text-[10px] text-chart-2">{{ statusToolLabel }}</span>
|
||||
</template>
|
||||
<template v-else-if="generationStatusRunningTool">
|
||||
<span v-if="statusTurnBadge" class="whitespace-nowrap rounded-[5px] border border-border px-[5px] font-mono text-[10px] text-muted-foreground">{{ statusTurnBadge }}</span>
|
||||
<span class="whitespace-nowrap">{{ t("ai.status.runningToolAction") }}</span>
|
||||
<span class="whitespace-nowrap rounded-[5px] border border-chart-2/30 bg-chart-2/12 px-1.5 py-px font-mono text-[10px] text-chart-2">{{ statusToolLabel }}</span>
|
||||
<span class="whitespace-nowrap tabular-nums">{{ t("ai.status.runningToolElapsed", { elapsed: statusElapsedSeconds }) }}</span>
|
||||
</template>
|
||||
<span v-else class="min-w-0 tabular-nums">{{ generationStatusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -3624,6 +3776,12 @@ async function openExternalUrl(url: string) {
|
||||
@paste="onPromptPaste"
|
||||
/>
|
||||
<input ref="csvFileInputRef" type="file" multiple accept="image/png,image/jpeg,image/gif,image/webp,.csv,.md,.markdown,.txt,.text,.json,.yaml,.yml,.xml,.log,.tsv" class="hidden" @change="onCsvFileSelected" />
|
||||
<!-- Gentle >60s hint (Issue #6743 feature 1): never asserts the request is
|
||||
stuck/hung, only that it is running long and may be waited on or stopped. -->
|
||||
<div v-if="statusLongRunningHintVisible" class="mb-1.5 flex items-center gap-1.5 rounded-[7px] border border-warning/30 bg-warning/10 px-[9px] py-[5px] text-[11px] text-warning">
|
||||
<Clock class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ t("ai.status.longRunningHint") }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
|
||||
+75
@@ -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("<Hourglass v-else");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2563,6 +2563,7 @@ export default {
|
||||
notRequested: "Run not requested",
|
||||
skipped: "Not run",
|
||||
callingTool: "Calling tool...",
|
||||
executing: "Executing…",
|
||||
toolDone: "Tool completed",
|
||||
toolError: "Tool error",
|
||||
executeSafe: "Safety check passed · Executed",
|
||||
@@ -2579,6 +2580,32 @@ export default {
|
||||
notRequested: "The user did not ask to run the SQL.",
|
||||
skipped: "This step was not run.",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "Waiting for model response · Running {elapsed}s",
|
||||
waitingModelLive: "Waiting for model response",
|
||||
generating: "Generating reply · Running {elapsed}s",
|
||||
generatingLive: "Generating reply",
|
||||
runningTool: "Turn {turn} · Running {tool} · {elapsed}s",
|
||||
runningToolAction: "· Running",
|
||||
runningToolElapsed: "· {elapsed}s",
|
||||
turnBadge: "Turn {turn}",
|
||||
idle: "Waiting for this step · Last activity {idle}s ago",
|
||||
idleLive: "Waiting for this step",
|
||||
idleWithTool: "Waiting for this step · Last activity {idle}s ago · Running {tool}",
|
||||
longRunningHint: "This is taking longer than expected. You can keep waiting or stop.",
|
||||
cancelling: "Cancelling…",
|
||||
toolLabels: {
|
||||
executeQuery: "Execute query",
|
||||
executeSql: "Execute SQL",
|
||||
listTables: "List tables",
|
||||
getColumns: "Get columns",
|
||||
getCurrentTime: "Get current time",
|
||||
explainQuery: "Explain query plan",
|
||||
getSampleData: "Get sample data",
|
||||
listCollections: "List collections",
|
||||
browseCollection: "Browse collection",
|
||||
},
|
||||
},
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Send AI requests through proxy",
|
||||
proxyUrl: "Proxy URL",
|
||||
|
||||
@@ -2639,6 +2639,7 @@ export default withEnglishFallback({
|
||||
notRequested: "No se solicitó ejecución",
|
||||
skipped: "No ejecutado",
|
||||
callingTool: "Llamando herramienta...",
|
||||
executing: "Ejecutando…",
|
||||
toolDone: "Herramienta completada",
|
||||
toolError: "Error de herramienta",
|
||||
executeSafe: "Verificación de seguridad superada · Ejecutado",
|
||||
@@ -2655,6 +2656,32 @@ export default withEnglishFallback({
|
||||
notRequested: "El usuario no solicitó ejecutar el SQL.",
|
||||
skipped: "Este paso no se ejecutó.",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "Esperando respuesta del modelo · En ejecución {elapsed}s",
|
||||
waitingModelLive: "Esperando respuesta del modelo",
|
||||
generating: "Generando respuesta · En ejecución {elapsed}s",
|
||||
generatingLive: "Generando respuesta",
|
||||
runningTool: "Turno {turn} · Ejecutando {tool} · {elapsed}s",
|
||||
runningToolAction: "· Ejecutando",
|
||||
runningToolElapsed: "· {elapsed}s",
|
||||
turnBadge: "Turno {turn}",
|
||||
idle: "Esperando este paso · Última actividad hace {idle}s",
|
||||
idleLive: "Esperando este paso",
|
||||
idleWithTool: "Esperando este paso · Última actividad hace {idle}s · Ejecutando {tool}",
|
||||
longRunningHint: "Esto está tardando más de lo esperado. Puedes seguir esperando o detenerte.",
|
||||
cancelling: "Cancelando…",
|
||||
toolLabels: {
|
||||
executeQuery: "Ejecutar consulta",
|
||||
executeSql: "Ejecutar SQL",
|
||||
listTables: "Listar tablas",
|
||||
getColumns: "Obtener columnas",
|
||||
getCurrentTime: "Obtener hora actual",
|
||||
explainQuery: "Explicar plan de ejecución",
|
||||
getSampleData: "Obtener datos de muestra",
|
||||
listCollections: "Listar colecciones",
|
||||
browseCollection: "Explorar colecciones",
|
||||
},
|
||||
},
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Enviar solicitudes de IA mediante proxy",
|
||||
proxyUrl: "URL del proxy",
|
||||
|
||||
@@ -2533,6 +2533,7 @@ export default withEnglishFallback({
|
||||
notRequested: "Esecuzione non richiesta",
|
||||
skipped: "Non eseguito",
|
||||
callingTool: "Chiamata strumento...",
|
||||
executing: "In esecuzione…",
|
||||
toolDone: "Strumento completato",
|
||||
toolError: "Errore strumento",
|
||||
executeSafe: "Controllo di sicurezza superato · Eseguito",
|
||||
@@ -2549,6 +2550,32 @@ export default withEnglishFallback({
|
||||
notRequested: "L'utente non ha richiesto l'esecuzione dell'SQL.",
|
||||
skipped: "Questo passaggio non è stato eseguito.",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "In attesa della risposta del modello · In esecuzione da {elapsed}s",
|
||||
waitingModelLive: "In attesa della risposta del modello",
|
||||
generating: "Generazione della risposta · In esecuzione da {elapsed}s",
|
||||
generatingLive: "Generazione della risposta",
|
||||
runningTool: "Turno {turn} · Esecuzione di {tool} · {elapsed}s",
|
||||
runningToolAction: "· Esecuzione",
|
||||
runningToolElapsed: "· {elapsed}s",
|
||||
turnBadge: "Turno {turn}",
|
||||
idle: "In attesa di questo passaggio · Ultima attività {idle}s fa",
|
||||
idleLive: "In attesa di questo passaggio",
|
||||
idleWithTool: "In attesa di questo passaggio · Ultima attività {idle}s fa · Esecuzione di {tool}",
|
||||
longRunningHint: "L'operazione sta richiedendo più tempo del previsto. Puoi continuare ad attendere o interrompere.",
|
||||
cancelling: "Annullamento…",
|
||||
toolLabels: {
|
||||
executeQuery: "Esegui query",
|
||||
executeSql: "Esegui SQL",
|
||||
listTables: "Elenca tabelle",
|
||||
getColumns: "Ottieni colonne",
|
||||
getCurrentTime: "Ottieni ora corrente",
|
||||
explainQuery: "Spiega piano di esecuzione",
|
||||
getSampleData: "Ottieni dati di esempio",
|
||||
listCollections: "Elenca collezioni",
|
||||
browseCollection: "Sfoglia collezioni",
|
||||
},
|
||||
},
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Invia richieste AI tramite proxy",
|
||||
proxyUrl: "URL Proxy",
|
||||
|
||||
@@ -2672,6 +2672,7 @@ export default withEnglishFallback({
|
||||
notRequested: "実行は要求されていません",
|
||||
skipped: "未実行",
|
||||
callingTool: "ツールを呼び出し中...",
|
||||
executing: "実行中…",
|
||||
toolDone: "ツール完了",
|
||||
toolError: "ツールエラー",
|
||||
executeSafe: "安全チェック通過 · 実行済み",
|
||||
@@ -2688,6 +2689,32 @@ export default withEnglishFallback({
|
||||
notRequested: "ユーザーがSQLの実行を要求しませんでした。",
|
||||
skipped: "このステップは実行されませんでした。",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "モデルの応答を待機中 · 実行時間 {elapsed}s",
|
||||
waitingModelLive: "モデルの応答を待機中",
|
||||
generating: "返信を生成中 · 実行時間 {elapsed}s",
|
||||
generatingLive: "返信を生成中",
|
||||
runningTool: "{turn} ラウンド目 · {tool} を実行中 · 実行時間 {elapsed}s",
|
||||
runningToolAction: "· 実行中",
|
||||
runningToolElapsed: "· 実行時間 {elapsed}s",
|
||||
turnBadge: "{turn} ラウンド目",
|
||||
idle: "このステップの完了を待機中 · 最後のアクティビティ {idle}s 前",
|
||||
idleLive: "このステップの完了を待機中",
|
||||
idleWithTool: "このステップの完了を待機中 · 最後のアクティビティ {idle}s 前 · {tool} を実行中",
|
||||
longRunningHint: "通常より時間がかかっています。このまま待つか停止できます。",
|
||||
cancelling: "キャンセル中…",
|
||||
toolLabels: {
|
||||
executeQuery: "クエリを実行",
|
||||
executeSql: "SQL を実行",
|
||||
listTables: "テーブル一覧",
|
||||
getColumns: "カラムを取得",
|
||||
getCurrentTime: "現在時刻を取得",
|
||||
explainQuery: "実行計画を説明",
|
||||
getSampleData: "サンプルデータを取得",
|
||||
listCollections: "コレクション一覧",
|
||||
browseCollection: "コレクションを閲覧",
|
||||
},
|
||||
},
|
||||
proxy: "プロキシ",
|
||||
proxyEnable: "プロキシ経由でAIリクエストを送信",
|
||||
proxyUrl: "プロキシURL",
|
||||
|
||||
@@ -2388,6 +2388,7 @@ export default withEnglishFallback({
|
||||
notRequested: "실행 요청되지 않음",
|
||||
skipped: "실행되지 않음",
|
||||
callingTool: "도구 호출 중...",
|
||||
executing: "실행 중…",
|
||||
toolDone: "도구 완료",
|
||||
toolError: "도구 오류",
|
||||
executeSafe: "안전성 검사 통과 · 실행됨",
|
||||
@@ -2404,6 +2405,32 @@ export default withEnglishFallback({
|
||||
notRequested: "사용자가 SQL 실행을 요청하지 않았습니다.",
|
||||
skipped: "이 단계는 실행되지 않았습니다.",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "모델 응답 대기 중 · 실행 {elapsed}s",
|
||||
waitingModelLive: "모델 응답 대기 중",
|
||||
generating: "답변 생성 중 · 실행 {elapsed}s",
|
||||
generatingLive: "답변 생성 중",
|
||||
runningTool: "{turn}라운드 · {tool} 실행 중 · 실행 {elapsed}s",
|
||||
runningToolAction: "· 실행 중",
|
||||
runningToolElapsed: "· 실행 {elapsed}s",
|
||||
turnBadge: "{turn}라운드",
|
||||
idle: "이 단계 완료 대기 중 · 마지막 활동 {idle}s 전",
|
||||
idleLive: "이 단계 완료 대기 중",
|
||||
idleWithTool: "이 단계 완료 대기 중 · 마지막 활동 {idle}s 전 · {tool} 실행 중",
|
||||
longRunningHint: "예상보다 오래 걸리고 있습니다. 계속 기다리거나 중지할 수 있습니다.",
|
||||
cancelling: "취소 중…",
|
||||
toolLabels: {
|
||||
executeQuery: "쿼리 실행",
|
||||
executeSql: "SQL 실행",
|
||||
listTables: "테이블 나열",
|
||||
getColumns: "컬럼 가져오기",
|
||||
getCurrentTime: "현재 시간 가져오기",
|
||||
explainQuery: "실행 계획 설명",
|
||||
getSampleData: "샘플 데이터 가져오기",
|
||||
listCollections: "컬렉션 나열",
|
||||
browseCollection: "컬렉션 탐색",
|
||||
},
|
||||
},
|
||||
proxy: "프록시",
|
||||
proxyEnable: "프록시를 통해 AI 요청 전송",
|
||||
proxyUrl: "프록시 URL",
|
||||
|
||||
@@ -2641,6 +2641,7 @@ export default withEnglishFallback({
|
||||
notRequested: "Execução não solicitada",
|
||||
skipped: "Não executado",
|
||||
callingTool: "Chamando ferramenta...",
|
||||
executing: "Executando…",
|
||||
toolDone: "Ferramenta concluída",
|
||||
toolError: "Erro da ferramenta",
|
||||
executeSafe: "Verificação de segurança aprovada · Executado",
|
||||
@@ -2657,6 +2658,32 @@ export default withEnglishFallback({
|
||||
notRequested: "O usuário não solicitou a execução do SQL.",
|
||||
skipped: "Esta etapa não foi executada.",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "Aguardando resposta do modelo · Em execução há {elapsed}s",
|
||||
waitingModelLive: "Aguardando resposta do modelo",
|
||||
generating: "Gerando resposta · Em execução há {elapsed}s",
|
||||
generatingLive: "Gerando resposta",
|
||||
runningTool: "Turno {turn} · Executando {tool} · {elapsed}s",
|
||||
runningToolAction: "· Executando",
|
||||
runningToolElapsed: "· {elapsed}s",
|
||||
turnBadge: "Turno {turn}",
|
||||
idle: "Aguardando esta etapa · Última atividade há {idle}s",
|
||||
idleLive: "Aguardando esta etapa",
|
||||
idleWithTool: "Aguardando esta etapa · Última atividade há {idle}s · Executando {tool}",
|
||||
longRunningHint: "Isso está demorando mais que o esperado. Você pode continuar aguardando ou parar.",
|
||||
cancelling: "Cancelando…",
|
||||
toolLabels: {
|
||||
executeQuery: "Executar consulta",
|
||||
executeSql: "Executar SQL",
|
||||
listTables: "Listar tabelas",
|
||||
getColumns: "Obter colunas",
|
||||
getCurrentTime: "Obter hora atual",
|
||||
explainQuery: "Explicar plano de execução",
|
||||
getSampleData: "Obter dados de amostra",
|
||||
listCollections: "Listar coleções",
|
||||
browseCollection: "Navegar coleções",
|
||||
},
|
||||
},
|
||||
proxy: "Proxy",
|
||||
proxyEnable: "Enviar requisições de AI através do proxy",
|
||||
proxyUrl: "URL do Proxy",
|
||||
|
||||
@@ -2487,6 +2487,7 @@ export default withEnglishFallback({
|
||||
notRequested: "未请求执行",
|
||||
skipped: "未执行",
|
||||
callingTool: "正在调用工具...",
|
||||
executing: "执行中…",
|
||||
toolDone: "工具调用完成",
|
||||
toolError: "工具调用出错",
|
||||
executeSafe: "安全检查通过 · 已执行",
|
||||
@@ -2503,6 +2504,32 @@ export default withEnglishFallback({
|
||||
notRequested: "用户没有表达执行意图。",
|
||||
skipped: "此步骤未执行。",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "等待模型响应 · 已运行 {elapsed}s",
|
||||
waitingModelLive: "等待模型响应",
|
||||
generating: "正在生成回复 · 已运行 {elapsed}s",
|
||||
generatingLive: "正在生成回复",
|
||||
runningTool: "第 {turn} 轮 · 正在执行 {tool} · 已运行 {elapsed}s",
|
||||
runningToolAction: "· 正在执行",
|
||||
runningToolElapsed: "· 已运行 {elapsed}s",
|
||||
turnBadge: "第 {turn} 轮",
|
||||
idle: "等待此步骤完成 · 最后活动 {idle}s 前",
|
||||
idleLive: "等待此步骤完成",
|
||||
idleWithTool: "等待此步骤完成 · 最后活动 {idle}s 前 · 正在执行 {tool}",
|
||||
longRunningHint: "响应时间较长,可继续等待或停止",
|
||||
cancelling: "正在取消…",
|
||||
toolLabels: {
|
||||
executeQuery: "执行查询",
|
||||
executeSql: "执行 SQL",
|
||||
listTables: "列出数据表",
|
||||
getColumns: "读取字段",
|
||||
getCurrentTime: "获取当前时间",
|
||||
explainQuery: "解释查询计划",
|
||||
getSampleData: "获取样例数据",
|
||||
listCollections: "列出集合",
|
||||
browseCollection: "浏览集合",
|
||||
},
|
||||
},
|
||||
proxy: "代理",
|
||||
proxyEnable: "AI 请求通过代理发送",
|
||||
proxyUrl: "代理地址",
|
||||
|
||||
@@ -2533,6 +2533,7 @@ export default withEnglishFallback({
|
||||
notRequested: "未請求執行",
|
||||
skipped: "未執行",
|
||||
callingTool: "正在調用工具...",
|
||||
executing: "執行中…",
|
||||
toolDone: "工具調用完成",
|
||||
toolError: "工具調用出錯",
|
||||
executeSafe: "安全檢查通過 · 已執行",
|
||||
@@ -2549,6 +2550,32 @@ export default withEnglishFallback({
|
||||
notRequested: "使用者沒有表達執行意圖。",
|
||||
skipped: "此步驟未執行。",
|
||||
},
|
||||
status: {
|
||||
waitingModel: "等待模型回應 · 已運行 {elapsed}s",
|
||||
waitingModelLive: "等待模型回應",
|
||||
generating: "正在生成回覆 · 已運行 {elapsed}s",
|
||||
generatingLive: "正在生成回覆",
|
||||
runningTool: "第 {turn} 輪 · 正在執行 {tool} · 已運行 {elapsed}s",
|
||||
runningToolAction: "· 正在執行",
|
||||
runningToolElapsed: "· 已運行 {elapsed}s",
|
||||
turnBadge: "第 {turn} 輪",
|
||||
idle: "等待此步驟完成 · 最後活動 {idle}s 前",
|
||||
idleLive: "等待此步驟完成",
|
||||
idleWithTool: "等待此步驟完成 · 最後活動 {idle}s 前 · 正在執行 {tool}",
|
||||
longRunningHint: "回應時間較長,可繼續等待或停止",
|
||||
cancelling: "正在取消…",
|
||||
toolLabels: {
|
||||
executeQuery: "執行查詢",
|
||||
executeSql: "執行 SQL",
|
||||
listTables: "列出資料表",
|
||||
getColumns: "讀取欄位",
|
||||
getCurrentTime: "取得目前時間",
|
||||
explainQuery: "說明執行計畫",
|
||||
getSampleData: "取得範例資料",
|
||||
listCollections: "列出集合",
|
||||
browseCollection: "瀏覽集合",
|
||||
},
|
||||
},
|
||||
proxy: "代理伺服器",
|
||||
proxyEnable: "經由代理伺服器發送 AI 請求",
|
||||
proxyUrl: "代理伺服器地址",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<u32>, output_tokens: Option<u32> },
|
||||
/// Context was compacted to stay within context window limits.
|
||||
ContextCompacted {
|
||||
|
||||
@@ -1000,6 +1000,23 @@ fn grok_usage_tokens(usage: &Value) -> Option<TokenUsage> {
|
||||
(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<AgentEvent> = None;
|
||||
let mut total_usage = TokenUsage::default();
|
||||
let mut terminal_error: Option<String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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"\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user