feat(ai): run agent sessions as background tasks

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-26 14:09:08 +08:00
1 parent b37a1f8538
commit eeeb4e5011
34 files changed
+3440 -184

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -33,7 +33,7 @@ function bodyOf(fnSignature: string): string {
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("AI assistant clear/switch cancels an in-flight request (issue #5941, PR #6332)", () => {
describe("AI assistant uses platform-specific conversation lifecycle", () => {
it("abandonInFlightRequest() invalidates the generation guard before/regardless of the backend RPC", () => {
const body = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)");
expect(body).toContain("aiGenerationGuard.invalidate();");
@@ -45,17 +45,19 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
expect(body.indexOf("aiGenerationGuard.invalidate();")).toBeLessThan(body.indexOf("if (sessionId && sessionId !== alreadyCancelledSessionId) {"));
});
it("clearMessages() abandons a stuck stream before wiping history", () => {
it("clearMessages() keeps Web cancellation while Desktop detaches the view", () => {
const body = bodyOf("function clearMessages()");
expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();");
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
expect(body).toContain("if (isGenerating.value && backgroundAiRunsEnabled) void persistConversation();");
// The abandon guard must run before the history is actually wiped, not after.
expect(body.indexOf("if (isGenerating.value) abandonInFlightRequest();")).toBeLessThan(body.indexOf("messages.value = []"));
expect(body.indexOf("!backgroundAiRunsEnabled) abandonInFlightRequest();")).toBeLessThan(body.indexOf("messages.value = []"));
});
it("selectConversation() abandons a stuck stream before switching conversations", () => {
it("selectConversation() cancels on Web and reattaches to a Desktop run", () => {
const body = bodyOf("function selectConversation(conv: AiConversation)");
expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();");
expect(body.indexOf("if (isGenerating.value) abandonInFlightRequest();")).toBeLessThan(body.indexOf("conversationId.value = conv.id;"));
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
expect(body).toContain("desktopAiRun<ChatMessage>(conv.id)");
expect(body.indexOf("!backgroundAiRunsEnabled) abandonInFlightRequest();")).toBeLessThan(body.indexOf("conversationId.value = conv.id;"));
});
it("cancelStream() delegates live state readers to the tested stop lifecycle", () => {
@@ -67,15 +69,97 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
expect(body).toContain("persistConversation,");
});
it("startNewChat() clears through the guarded path and deleteConversation() uses the tested delete lifecycle", () => {
it("startNewChat() clears through the guarded path and performDeleteConversation() uses the tested delete lifecycle", () => {
expect(bodyOf("function startNewChat()")).toContain("clearMessages();");
const deleteBody = bodyOf("async function deleteConversation(id: string)");
const deleteBody = bodyOf("async function performDeleteConversation(id: string)");
expect(deleteBody).toContain("await deleteConversationWithCancellation({");
expect(deleteBody).toContain("abandon: () => abandonInFlightRequest()");
expect(deleteBody).toContain("deletePersisted: () => deleteAiConversation(id).catch(() => {})");
expect(deleteBody).toContain("if (conversationId.value === id) clearMessages();");
});
it("deleteConversation() asks for confirmation only when the conversation owns an active task", () => {
// Parent PRD §4 line 70: deleting a conversation with a running/queued/
// awaiting task must first ask; a conversation without an active task still
// deletes directly.
const deleteBody = bodyOf("async function deleteConversation(id: string)");
expect(deleteBody).toContain("if (backgroundAiRunsEnabled && conversationHasActiveTask(id)) {");
expect(deleteBody).toContain("deleteConfirmConversationId.value = id;");
expect(deleteBody).toContain("await performDeleteConversation(id);");
const taskBody = bodyOf("function conversationHasActiveTask(id: string)");
expect(taskBody).toContain('status === "awaiting_write_confirmation"');
expect(taskBody).toContain('status === "pending_recoverable"');
});
it("performDeleteConversation() marks desktop runs discardOnFinish so no async write can resurrect them", () => {
// Reviewed finding (delete-resurrection, HIGH): a queued/running background
// run's snapshot persists fired AFTER deleteAiConversation committed, so the
// INSERT OR REPLACE recreated the just-deleted conversation. The delete path
// must flag the run so every persist site skips it.
const body = bodyOf("async function performDeleteConversation(id: string)");
expect(body).toContain("run.discardOnFinish = true;");
expect(body).toContain('if (run.status === "queued") cancelQueuedDesktopAiRun(run);');
// The run is never finished here: the delete removes it from the registry
// immediately and the bounded background cleanup owns finalization, so a
// finished-but-still-slot-holding run can't wedge the queue.
expect(body).not.toContain('finishDesktopAiRun(run, "cancelled");');
expect(body).toContain("releaseDeletedRunSlot(run);");
// The confirm path must cancel runs in ALL active statuses, including a
// pending write confirmation and a recovered draft.
expect(body).toContain('run.status === "awaiting_write_confirmation"');
expect(body).toContain('run.status === "pending_recoverable"');
});
it("performDeleteConversation() launches bounded slot release so a deleted-but-hung run cannot wedge the queue", () => {
// Reviewed finding (slot leak, HIGH): the delete path removed the run from
// the registry immediately but never released its admittedRunIds slot when
// the backend stream was hung — after DESKTOP_AI_CONCURRENCY_LIMIT such
// deletes the global queue wedges permanently. The bounded release waits
// for the pipeline's settled signal (or the force-abandon deadline) and
// frees the slot; it also must not block the delete on the cancel RPC.
const releaseBody = bodyOf("function releaseDeletedRunSlot(run: DesktopAiRunRuntime<ChatMessage>)");
expect(releaseBody).toContain("if (!run.settled) return;");
expect(releaseBody).toContain("waitForDesktopRunSettled(run)");
expect(releaseBody).toContain("releaseDesktopAiRunSlot(run.runId)");
expect(releaseBody).toContain("requestDesktopRunCancellation(run, deadlineAt, false)");
const deleteBody = bodyOf("async function performDeleteConversation(id: string)");
expect(deleteBody).not.toContain("await aiCancelStream(");
});
it("send() guards every snapshot persist behind discardOnFinish so a deleted conversation can't be resurrected", () => {
const sendBody = bodyOf("async function send()");
// All three send()-internal persist sites (queued persist, unslotted
// early-return, and the pre-stream snapshot) must be guarded — previously
// some persisted unconditionally.
const guardedSites = sendBody.match(/if \(!detachedRun\.discardOnFinish\) void runSnapshotScheduler\.save\(detachedRun\);/g) ?? [];
expect(guardedSites.length).toBe(3);
// The finally must skip the run snapshot when the run was deleted, and the
// Web-only conversation fallback must not fire for a deleted Desktop run.
const finallyIdx = sendBody.indexOf("} finally {");
const finallyBody = sendBody.slice(finallyIdx);
expect(finallyBody).toContain("if (!detachedRun.discardOnFinish) {");
expect(finallyBody).not.toContain("else void persistConversationSnapshot(");
});
it("send() tags a queued run's FIFO category so restart recovery can classify it", () => {
const sendBody = bodyOf("async function send()");
const queueIdx = sendBody.indexOf('if (detachedRun.status === "queued")');
expect(queueIdx).toBeGreaterThanOrEqual(0);
const queuedBlock = sendBody.slice(queueIdx);
expect(queuedBlock).toContain('fifoCategory: resumingConfirmedWrite ? "write_confirmation_resume" : "normal_send"');
expect(queuedBlock).toContain("pendingInput: resumingConfirmedWrite ? detachedRun.pendingInput : text");
});
it("send() discards a recovered pending-input run before starting a fresh one", () => {
const sendBody = bodyOf("async function send()");
const registerIdx = sendBody.indexOf("const resumableRun = desktopAiRun<ChatMessage>(runConversationId);");
expect(registerIdx).toBeGreaterThanOrEqual(0);
const registerBlock = sendBody.slice(registerIdx);
expect(registerBlock).toContain('resumableRun?.status === "pending_recoverable"');
expect(registerBlock).toContain("resumableRun.discardOnFinish = true;");
expect(registerBlock).toContain("removeDesktopAiRun(resumableRun.conversationId);");
});
it("send() claims a generation id right after setting isGenerating and re-checks it after the first await", () => {
const sendBody = bodyOf("async function send()");
const claimIdx = sendBody.indexOf("const myGeneration = aiGenerationGuard.begin();");
@@ -85,7 +169,7 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
// anything that touches messages/mentions belonging to whatever conversation
// is current by the time it resolves.
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()");
const recheckIdx = sendBody.indexOf("if (!aiGenerationGuard.isCurrent(myGeneration))", ensureLoadedAwaitIdx);
const recheckIdx = sendBody.indexOf("if (!generationCanContinue())", ensureLoadedAwaitIdx);
expect(ensureLoadedAwaitIdx).toBeGreaterThan(claimIdx);
expect(recheckIdx).toBeGreaterThan(ensureLoadedAwaitIdx);
});
@@ -100,7 +184,7 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
const sendBody = bodyOf("async function send()");
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()");
const grantConsumedIdx = sendBody.indexOf("const allowWriteSql = requestedMode", ensureLoadedAwaitIdx);
const recheckIdx = sendBody.indexOf("if (!aiGenerationGuard.isCurrent(myGeneration)) {", ensureLoadedAwaitIdx);
const recheckIdx = sendBody.indexOf("if (!generationCanContinue()) {", ensureLoadedAwaitIdx);
expect(recheckIdx).toBeGreaterThan(ensureLoadedAwaitIdx);
expect(recheckIdx).toBeLessThan(grantConsumedIdx);
const recheckEnd = sendBody.indexOf("}", recheckIdx);
@@ -113,7 +197,7 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
const sendBody = bodyOf("async function send()");
const callbackStart = sendBody.indexOf("(event: AgentEvent) => {");
expect(callbackStart).toBeGreaterThanOrEqual(0);
const guardIdx = sendBody.indexOf("if (!aiGenerationGuard.isCurrent(myGeneration)) return;", callbackStart);
const guardIdx = sendBody.indexOf("if (!generationCanContinue()) return;", callbackStart);
const pushIdx = sendBody.indexOf("agentEvents.push(event);", callbackStart);
expect(guardIdx).toBeGreaterThan(callbackStart);
expect(guardIdx).toBeLessThan(pushIdx);
@@ -131,8 +215,8 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
// either throw (silently eating the real error) or corrupt a different
// conversation's transcript.
expect(catchBody).not.toContain("messages.value[assistantIdx].content =");
expect(catchBody).toContain("aiGenerationGuard.isCurrent(myGeneration)");
expect(catchBody).toContain("const msg = messages.value[assistantIdx];");
expect(catchBody).toContain("generationCanContinue()");
expect(catchBody).toContain("const msg = runMessages[assistantIdx];");
expect(catchBody).toContain("if (msg) msg.content =");
});
@@ -141,7 +225,7 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
const start = sendBody.indexOf("} finally {");
expect(start).toBeGreaterThanOrEqual(0);
const finallyBody = sendBody.slice(start);
const guardIdx = finallyBody.indexOf("if (aiGenerationGuard.isCurrent(myGeneration)) {");
const guardIdx = finallyBody.indexOf("if (detachedRun || aiGenerationGuard.isCurrent(myGeneration)) {");
expect(guardIdx).toBeGreaterThanOrEqual(0);
const isGeneratingIdx = finallyBody.indexOf("isGenerating.value = false;");
const sessionResetIdx = finallyBody.indexOf('currentSessionId.value = "";');
@@ -199,9 +283,9 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
it("send() rechecks the generation after EACH context-preparation await, before ever starting the stream", () => {
const sendBody = bodyOf("async function send()");
const sqlFilesIdx = sendBody.indexOf("await loadReferencedSqlFiles(");
const firstRecheckIdx = sendBody.indexOf("if (!aiGenerationGuard.isCurrent(myGeneration)) return;", sqlFilesIdx);
const firstRecheckIdx = sendBody.indexOf("if (!generationCanContinue()) return;", sqlFilesIdx);
const contextIdx = sendBody.indexOf("const context = await buildAiContext(");
const secondRecheckIdx = sendBody.indexOf("if (!aiGenerationGuard.isCurrent(myGeneration)) return;", contextIdx);
const secondRecheckIdx = sendBody.indexOf("if (!generationCanContinue()) return;", contextIdx);
const runIdx = sendBody.indexOf("await runAgentStream(", contextIdx);
// A clear/switch/unmount firing during EITHER await invalidates the generation
// but doesn't touch the backend (no session registered yet) — without a
@@ -221,8 +305,8 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
// "finalizes the stuck placeholder message" test above). Must be set at the
// same point currentSessionId is, and cleared alongside it too.
const sendBody = bodyOf("async function send()");
const pushIdx = sendBody.indexOf('messages.value.push({ role: "assistant"');
const assistantIdxIdx = sendBody.indexOf("const assistantIdx = messages.value.length - 1;", pushIdx);
const pushIdx = sendBody.indexOf('runMessages.push({ role: "assistant"');
const assistantIdxIdx = sendBody.indexOf("const assistantIdx = runMessages.length - 1;", pushIdx);
const trackIdx = sendBody.indexOf("currentAssistantMessageIndex = assistantIdx;", assistantIdxIdx);
const sessionSetIdx = sendBody.indexOf("currentSessionId.value = sessionId;", trackIdx);
expect(pushIdx).toBeGreaterThanOrEqual(0);
@@ -286,10 +370,35 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
// callback/catch/finally would keep writing into refs this now-unmounted
// instance's closures still hold. Must go through the same invalidating path as
// clearMessages()/selectConversation().
expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();");
expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
expect(body).not.toContain("cancelStream();");
});
it("queues simultaneous background auto-sends instead of overwriting a single pending target", () => {
// Several detached runs can settle in the same turn. A single mutable
// `pendingAutoSend` slot lost whichever queued input was scheduled first.
expect(source).toContain("const pendingAutoSends: PendingAutoSend[] = [];");
const sendBody = bodyOf("async function send()");
expect(sendBody).toContain("const auto = pendingAutoSends.shift() ?? null;");
const scheduleBody = bodyOf("function scheduleAutoSend(");
expect(scheduleBody).toContain("pendingAutoSends.push(");
});
it("makes successfully persisted background conversations navigable without reopening the list", () => {
const snapshotBody = bodyOf("async function persistDesktopRunSnapshot(");
expect(snapshotBody).toContain(".then(() => syncPersistedConversation(conversation))");
const syncBody = bodyOf("function syncPersistedConversation(conversation: AiConversation)");
expect(syncBody).toContain("conversations.value.unshift(conversation);");
expect(syncBody).toContain("conversations.value.sort(");
});
it("does not replace an in-memory desktop run when the AI panel remounts", () => {
const mountedBody = bodyOf("onMounted(async () => {");
expect(mountedBody).toContain("const liveRun = desktopAiRun<ChatMessage>(persistedRun.conversationId);");
expect(mountedBody).toContain("if (liveRun) {");
expect(mountedBody).toContain("continue;");
});
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
@@ -314,4 +423,115 @@ describe("AI assistant clear/switch cancels an in-flight request (issue #5941, P
const statusBlock = source.slice(statusLineIdx);
expect(statusBlock).toContain("<Hourglass v-else");
});
// --- Review findings on background AI session tasks (see the two findings
// evaluated in this session) ---
it("persists detached streaming snapshots incrementally from the delta flush path", () => {
// Finding 1: detached deltas were flushed only into memory (plus a scroll
// callback), so a crash/quit mid-response lost everything after the
// pre-stream snapshot. The flush path must now schedule throttled,
// serialized snapshot persistence.
const sendBody = bodyOf("async function send()");
const bufferIdx = sendBody.indexOf("createDetachedAssistantDeltaBuffer(runMessages, () => {");
expect(bufferIdx).toBeGreaterThanOrEqual(0);
const flushBlock = sendBody.slice(bufferIdx, sendBody.indexOf(": undefined", bufferIdx));
expect(flushBlock).toContain("runSnapshotScheduler.schedule(detachedRun);");
// The pre-stream snapshot and the finally go through the same serialized
// scheduler so a slow write can never reorder around them.
expect(sendBody).toContain("void runSnapshotScheduler.save(detachedRun)");
// The scheduler itself lives at module scope (the flush callback and the
// stop path both use it), configured with the streaming interval.
expect(source).toContain("const runSnapshotScheduler = createDesktopAiRunSnapshotScheduler<ChatMessage>({");
expect(source).toContain("intervalMs: RUN_SNAPSHOT_PERSIST_INTERVAL_MS");
});
it("send() resolves the run's settled signal on every exit path so a stop can wait for the real terminal event", () => {
// Finding 2: the stop path must not finalize the run itself; it waits for
// the pipeline's terminal event via the run's `settled` promise. Every
// exit path of send() must resolve it (finally + three pre-stream early
// returns), or a stop fired there would sit out the full timeout.
const sendBody = bodyOf("async function send()");
expect(sendBody).toContain("detachedRun.settled = new Promise<void>((resolve) => {");
const finallyIdx = sendBody.indexOf("} finally {");
const finallyBody = sendBody.slice(finallyIdx);
expect(finallyBody).toContain("resolveDetachedRunSettled();");
const settledCalls = sendBody.match(/resolveDetachedRunSettled\(\);/g) ?? [];
expect(settledCalls.length).toBeGreaterThanOrEqual(4);
});
it("the finally skips finalization for a run that no longer owns its registry slot", () => {
// Finding 2: after a stop-side force-abandon (or a delete/replacement send)
// retires the run, the old pipeline's finally must not re-run the finish
// chain - updateDesktopAiRun() re-inserts unconditionally, which would
// resurrect the retired run over whatever now owns the conversation.
const sendBody = bodyOf("async function send()");
const finallyIdx = sendBody.indexOf("} finally {");
const finallyBody = sendBody.slice(finallyIdx);
expect(finallyBody).toContain("const runStillOwned = detachedRun ? desktopAiRun(detachedRun.conversationId) === detachedRun : false;");
expect(finallyBody).toContain("if (detachedRun && runStillOwned) {");
expect(finallyBody).toContain("runSnapshotScheduler.cancel(detachedRun.runId);");
expect(finallyBody).toContain("void runSnapshotScheduler.save(detachedRun).finally(");
expect(finallyBody).toContain("} else if (!detachedRun) {");
});
it("stopDesktopAiRun keeps a background run active until the pipeline settles, force-abandoning after the timeout", () => {
// Finding 2: the old stop path finished+retired immediately, so a hung or
// cancellation-pending stream went invisible while still occupying its slot.
// The shared helper must reflect the request but defer finalization to the
// pipeline's finally or the bounded force-abandon.
const body = bodyOf("async function stopDesktopAiRun(");
expect(body).toContain("run.cancelRequested = true;");
expect(body).toContain("waitForDesktopRunSettled(run)");
expect(body).toContain("requestDesktopRunCancellation(run, deadlineAt)");
expect(body).toContain("await forceAbandonDesktopAiRun(run);");
// Only the never-admitted queued branch finalizes immediately.
expect(body).toContain('if (run.status === "queued")');
expect(body).toContain("cancelQueuedDesktopAiRun(run);");
});
it("the stop path consumes the aiCancelStream acknowledgement and retries while unacknowledged", () => {
// Finding 2: aiCancelStream() returns a boolean (true only when the session
// id is registered with the backend) that was discarded. The retry loop
// lands the cancellation once the session registers during context
// preparation.
const body = bodyOf("async function requestDesktopRunCancellation(");
expect(body).toContain("const acknowledged = await aiCancelStream(run.currentSessionId).catch(() => false);");
expect(body).toContain("if (acknowledged) return;");
expect(body).toContain("DESKTOP_CANCEL_ACK_RETRY_MS");
});
it("stopDesktopAiRun fire-and-forgets the cancel retry so a hung IPC cannot block the bounded force-abandon", () => {
// Reviewed finding (deadlock, HIGH): stopDesktopAiRun() awaited
// requestDesktopRunCancellation(), which awaits aiCancelStream() directly.
// If the IPC never returned, the STOP_FORCE_ABANDON_MS race was never read
// and the stop never force-abandoned — a hung cancel RPC stranded the run
// exactly like the hung stream it was meant to recover from.
const body = bodyOf("async function stopDesktopAiRun(");
expect(body).toContain("void requestDesktopRunCancellation(run, deadlineAt);");
expect(body).not.toContain("await requestDesktopRunCancellation(run, deadlineAt);");
// The bounded settle race drives the force-abandon, not the cancel RPC.
expect(body).toContain("if (await settled) {");
});
it("both stop entry points delegate to the shared stopDesktopAiRun helper", () => {
const cancelBody = bodyOf("async function cancelStream()");
expect(cancelBody).toContain("await stopDesktopAiRun(run);");
expect(cancelBody).not.toContain('finishDesktopAiRun(run, "cancelled")');
const rowBody = bodyOf("async function stopConversationRun(");
expect(rowBody).toContain("await stopDesktopAiRun(run);");
});
it("keeps long task summaries out of the fixed-width history popover", () => {
// The history popover is always w-72, but min-[430px] responds to viewport
// width. On desktop it displayed long summaries inside the compact row,
// squeezing the conversation title and forcing horizontal scrolling. The
// status icon still exposes the full text through its title/ARIA metadata.
const historyStart = source.indexOf('<PopoverContent align="end" class="w-72 gap-0 p-0"');
const history = source.slice(historyStart);
expect(history).toContain(":title=\"conversationRowDetail(conv).summary ?? t('ai.runStatusCompleted')\"");
expect(history).toContain(":title=\"conversationRowDetail(conv).reason ?? t(conversationRowDetail(conv).status === 'interrupted' ? 'ai.runStatusInterrupted' : 'ai.runStatusFailed')\"");
expect(history).not.toContain("conversationRowDetail(conv).summary }}</span>");
expect(history).not.toContain("conversationRowDetail(conv).reason }}</span>");
});
});