feat(ai): run agent sessions as background tasks
This commit is contained in:
1 parent
b37a1f8538
commit
eeeb4e5011
34 files changed
+3440
-184
No files matched your search
+240
-20
@@ -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>");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user