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

+47 -3
View File
@@ -66,6 +66,7 @@ import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPa
import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
import {
isBrowserReloadShortcut,
isCloseOtherTabsShortcut,
@@ -143,6 +144,7 @@ const DriverStorePage = defineAsyncComponent(() => import("@/components/config/D
const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue"));
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
const AiRunsClosePromptDialog = defineAsyncComponent(() => import("@/components/layout/AiRunsClosePromptDialog.vue"));
const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue"));
const TabSwitcherDialog = defineAsyncComponent(() => import("@/components/tabs/TabSwitcherDialog.vue"));
@@ -154,6 +156,8 @@ type AiAssistantHandle = {
setPrompt: (text: string) => void;
addTableMention: (target: { schema?: string; table: string }) => void;
clearContextReferences: () => void;
/** Opens a conversation by id (used by the background-run toast, §9). */
selectConversationById: (conversationId: string) => void;
};
const { t } = useI18n();
@@ -180,7 +184,7 @@ connectionStore.setBeforeConnectHandler(async (config) => {
await connectionStore.updateConnection(config);
}
});
const { message: toastMessage, visible: toastVisible, toast } = useToast();
const { message: toastMessage, visible: toastVisible, action: toastAction, toast } = useToast();
const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
const { activeCount: activeBackgroundTaskCount } = useExportTracker();
const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs));
@@ -211,6 +215,10 @@ const {
const { setupFileDrop } = useFileDrop();
const isDesktop = isTauriRuntime();
const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().length : 0));
/** Runs waiting for a write confirmation — the panel-entry badge shows these
* with a higher-priority indicator (parent PRD §4 line 71 / §9). */
const awaitingAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().filter((run) => run.status === "awaiting_write_confirmation").length : 0));
const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged } = useMcpUpdateBadge({
isDesktop,
updateNotificationsEnabled: () => settingsStore.editorSettings.updateNotificationsEnabled,
@@ -307,6 +315,9 @@ const pendingPrevActiveTabId = ref<string | null>(null);
const pendingSaveShouldCloseTab = ref(true);
const pendingAppCloseAction = ref<AppCloseAction | null>(null);
const pendingCloseActionChoice = ref(false);
const showAiRunsClosePrompt = ref(false);
const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQuit().length : 0));
let aiRunsQuitConfirmed = false;
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
const tabNavigationHistory = ref(createTabNavigationHistory());
@@ -953,6 +964,16 @@ function openAiPanel() {
openRightSidebarPanel("ai");
}
/** A background AI run reached a terminal/confirmation state while the panel
* was closed. Opens the panel and selects the run's conversation (parent PRD
* §4 line 71 / §9 clickable toast). */
function handleAiRunNotify(event: Event) {
const conversationId = (event as CustomEvent).detail?.conversationId as string | undefined;
if (!conversationId) return;
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) => handle.selectConversationById(conversationId));
}
function analyzeHistoryWithAi(entry: HistoryEntry) {
const connectionId = entry.connection_id || activeTab.value?.connectionId;
if (!connectionId) {
@@ -1088,10 +1109,18 @@ function cancelPendingSaveAndClose() {
function cancelPendingAppClose() {
pendingAppCloseAction.value = null;
pendingCloseActionChoice.value = false;
showAiRunsClosePrompt.value = false;
aiRunsQuitConfirmed = false;
pendingSaveShouldCloseTab.value = true;
}
async function finishPendingAppClose(action: AppCloseAction) {
if (action === "quit" && !aiRunsQuitConfirmed && blockingAiRunCount.value > 0) {
pendingAppCloseAction.value = action;
showAiRunsClosePrompt.value = true;
return;
}
aiRunsQuitConfirmed = false;
if (pendingCloseActionChoice.value) {
pendingCloseActionChoice.value = false;
showCloseActionPrompt.value = true;
@@ -1120,6 +1149,13 @@ async function finishPendingAppClose(action: AppCloseAction) {
await performCloseAction(action);
}
function confirmQuitWithActiveAiRuns() {
const action = pendingAppCloseAction.value ?? "quit";
showAiRunsClosePrompt.value = false;
aiRunsQuitConfirmed = true;
void finishPendingAppClose(action);
}
function continuePendingAppCloseAfterSave() {
const action = pendingAppCloseAction.value;
if (!action) return;
@@ -2811,6 +2847,7 @@ onMounted(async () => {
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
window.addEventListener("dbx:activate-query-surface", activateQuerySurface);
window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged);
window.addEventListener("dbx:ai-run-notify", handleAiRunNotify);
if (isDesktop) {
document.addEventListener("contextmenu", handleContextMenu);
}
@@ -2886,6 +2923,7 @@ onUnmounted(() => {
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
window.removeEventListener("dbx:activate-query-surface", activateQuerySurface);
window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged);
window.removeEventListener("dbx:ai-run-notify", handleAiRunNotify);
document.removeEventListener("contextmenu", handleContextMenu);
window.clearTimeout(sqlLibraryFlyAnimationTimer);
});
@@ -2900,6 +2938,8 @@ onUnmounted(() => {
:is-dark="isDark"
:theme-mode="themeMode"
:show-ai-panel="showAiPanel"
:active-ai-run-count="activeAiRunCount"
:awaiting-ai-run-count="awaitingAiRunCount"
:show-history="showHistory"
:show-sql-library="showSqlLibraryPanel"
:sql-library-save-feedback-id="sqlLibrarySaveFeedbackId"
@@ -3241,6 +3281,7 @@ onUnmounted(() => {
/>
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
<AiRunsClosePromptDialog v-if="isDesktop && showAiRunsClosePrompt" v-model:open="showAiRunsClosePrompt" :count="blockingAiRunCount" @cancel="cancelPendingAppClose" @quit="confirmQuitWithActiveAiRuns" />
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
<TabSwitcherDialog :open="showTabSwitcher" :tabs="tabSwitcherTabs" :selected-index="tabSwitcherIndex" :shortcut-hint="tabSwitcherShortcutHint" @update:open="handleTabSwitcherOpenChange" @update:selected-index="tabSwitcherIndex = $event" @select="handleTabSwitcherSelect" />
</div>
@@ -3259,8 +3300,11 @@ onUnmounted(() => {
@animationend="finishSqlLibraryFlyAnimation(sqlLibraryFlyAnimation.id)"
/>
<Transition name="toast">
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 w-max max-w-[90vw] sm:max-w-3xl mx-auto z-99999 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text whitespace-pre-wrap break-words">
{{ toastMessage }}
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 mx-auto z-99999 w-max max-w-[90vw] sm:max-w-3xl px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text whitespace-pre-wrap break-words">
<span>{{ toastMessage }}</span>
<button v-if="toastAction" type="button" class="ml-3 shrink-0 rounded border border-background/40 bg-background/10 px-2 py-0.5 text-xs font-medium hover:bg-background/20" @click="toastAction.onClick()">
{{ toastAction.label }}
</button>
</div>
</Transition>
</Teleport>
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>");
});
});
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
defineProps<{ count: number }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ cancel: []; quit: [] }>();
const { t } = useI18n();
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[440px]" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("ai.closeWithRunsTitle") }}</DialogTitle>
<DialogDescription>{{ t("ai.closeWithRunsDescription", { count }) }}</DialogDescription>
</DialogHeader>
<DialogFooter class="gap-2 sm:gap-2">
<Button type="button" variant="outline" @click="emit('cancel')">{{ t("common.cancel") }}</Button>
<Button type="button" variant="destructive" @click="emit('quit')">{{ t("ai.closeWithRunsQuit") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -27,6 +27,10 @@ const props = defineProps<{
isDark: boolean;
themeMode: AppThemeMode;
showAiPanel: boolean;
activeAiRunCount: number;
/** Runs awaiting a write confirmation; the badge turns amber to outrank the
* plain active count (parent PRD §4 line 71). */
awaitingAiRunCount: number;
showHistory: boolean;
showSqlLibrary: boolean;
sqlLibrarySaveFeedbackId: number;
@@ -642,8 +646,19 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.ai">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('ai')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
<Button v-show="isRightItemVisible('ai')" variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
<Bot class="h-4 w-4" />
<span
v-if="awaitingAiRunCount > 0"
class="absolute right-0.5 top-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-semibold leading-none text-white"
:aria-label="t('ai.toolbarAwaitingConfirmation', { count: awaitingAiRunCount })"
:title="t('ai.toolbarAwaitingConfirmation', { count: awaitingAiRunCount })"
>
{{ awaitingAiRunCount > 9 ? "9+" : awaitingAiRunCount }}
</span>
<span v-else-if="activeAiRunCount > 0" class="absolute right-0.5 top-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-semibold leading-none text-primary-foreground">
{{ activeAiRunCount > 9 ? "9+" : activeAiRunCount }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>AI</TooltipContent>
+13 -2
View File
@@ -1,9 +1,12 @@
import { ref, type Ref } from "vue";
export type ToastAction = { label: string; onClick: () => void };
type ToastState = {
message: Ref<string>;
visible: Ref<boolean>;
timer: number;
action: Ref<ToastAction | undefined>;
};
declare global {
@@ -16,17 +19,25 @@ const toastState =
message: ref(""),
visible: ref(false),
timer: 0,
action: ref<ToastAction | undefined>(undefined),
});
export function useToast() {
function toast(msg: string, duration = 2000) {
function toast(msg: string, duration = 2000, action?: ToastAction) {
toastState.message.value = msg;
toastState.action.value = action;
toastState.visible.value = true;
clearTimeout(toastState.timer);
toastState.timer = window.setTimeout(() => {
toastState.visible.value = false;
toastState.action.value = undefined;
}, duration);
}
return { message: toastState.message, visible: toastState.visible, toast };
function dismissToast() {
toastState.visible.value = false;
toastState.action.value = undefined;
}
return { message: toastState.message, visible: toastState.visible, action: toastState.action, toast, dismissToast };
}
+38
View File
@@ -2514,6 +2514,44 @@ export default {
welcome: "Tell me what you'd like to query, and I'll write the SQL",
requestFailed: "AI request failed",
requestCancelled: "Request cancelled",
backgroundRunCompleted: "AI response completed in the background",
backgroundRunNeedsConfirmation: "AI needs your confirmation to continue",
recoveredDraftBanner: "A request that was queued before restart was recovered",
recoveredDraftHint: "Edit and resend, or remove it.",
discardDraft: "Remove draft",
runStatusRunning: "Running",
runStatusWaitingToStart: "Waiting to start",
runStatusAwaitingConfirmation: "Awaiting confirmation",
runStatusPendingDraft: "Pending input",
runStatusCompleted: "Completed",
runStatusFailed: "Failed",
runStatusInterrupted: "Interrupted",
runStatusCancelled: "Cancelled",
runUnread: "New updates in this conversation",
closeWithRunsTitle: "AI tasks are still running",
closeWithRunsDescription: "Quitting DBX will interrupt {count} running or queued AI task(s). You can close this dialog and let them finish instead.",
closeWithRunsQuit: "Quit and interrupt",
queueSend: "Queue send",
queueSendHint: "A task is still running for this conversation; the message will be sent when it finishes",
inputQueued: "Message queued to send after the current task",
queuedInputWaiting: "Waiting — {text}",
queuedInputPending: "Queued to send — {text}",
sendQueuedInput: "Send queued message",
queuedInputEdit: "Edit queued message",
queuedInputRemove: "Remove queued message",
rowQueuedInput: "Queued input",
retryRound: "Retry this round",
deleteConversation: "Delete conversation",
deleteActiveConversationTitle: "Conversation has an active task",
deleteActiveConversationDescription: "This conversation still has a task running or waiting. Cancel the task and delete the conversation, or keep it?",
cancelTaskAndDelete: "Cancel task and delete conversation",
keepConversation: "Keep conversation",
awayUpdatesDivider: "Updates while you were away",
jumpToLatest: "Jump to latest",
openPanel: "Open panel",
runRowPhaseTool: "Running {tool}",
runRowPhaseThinking: "Analyzing…",
toolbarAwaitingConfirmation: "{count} AI task(s) awaiting your confirmation",
newChat: "New Chat",
thinking: "Thinking...",
reasoningProcess: "Thinking process",
+38
View File
@@ -2548,6 +2548,44 @@ export default withEnglishFallback({
welcome: "Dime qué quieres consultar y escribiré el SQL",
requestFailed: "La solicitud de AI falló",
requestCancelled: "Solicitud de AI cancelada",
backgroundRunCompleted: "La respuesta de AI se completó en segundo plano",
backgroundRunNeedsConfirmation: "AI necesita tu confirmación para continuar",
recoveredDraftBanner: "Se recuperó una solicitud que estaba en cola antes del reinicio",
recoveredDraftHint: "Puedes editarla y reenviarla, o eliminarla.",
discardDraft: "Eliminar borrador",
runStatusRunning: "En ejecución",
runStatusWaitingToStart: "Esperando para comenzar",
runStatusAwaitingConfirmation: "Esperando confirmación",
runStatusPendingDraft: "Entrada pendiente",
runStatusCompleted: "Completado",
runStatusFailed: "Falló",
runStatusInterrupted: "Interrumpido",
runStatusCancelled: "Cancelado",
runUnread: "Hay novedades en esta conversación",
closeWithRunsTitle: "Las tareas de AI siguen en ejecución",
closeWithRunsDescription: "Salir de DBX interrumpirá {count} tarea(s) de AI en ejecución o en cola. También puedes cerrar este diálogo y dejar que terminen.",
closeWithRunsQuit: "Salir e interrumpir",
queueSend: "Encolar envío",
queueSendHint: "Esta conversación aún tiene una tarea en ejecución; el mensaje se enviará cuando termine",
inputQueued: "Mensaje en cola para enviarse después de la tarea actual",
queuedInputWaiting: "Esperando — {text}",
queuedInputPending: "En cola para enviar — {text}",
sendQueuedInput: "Enviar mensaje en cola",
queuedInputEdit: "Editar mensaje en cola",
queuedInputRemove: "Quitar mensaje en cola",
rowQueuedInput: "Entrada en cola",
retryRound: "Reintentar esta ronda",
deleteConversation: "Eliminar conversación",
deleteActiveConversationTitle: "La conversación tiene una tarea activa",
deleteActiveConversationDescription: "Esta conversación aún tiene una tarea en ejecución o en espera. ¿Cancelar la tarea y eliminar la conversación, o conservarla?",
cancelTaskAndDelete: "Cancelar tarea y eliminar conversación",
keepConversation: "Conservar conversación",
awayUpdatesDivider: "Novedades mientras no estabas",
jumpToLatest: "Ir a lo más reciente",
openPanel: "Abrir panel",
runRowPhaseTool: "Ejecutando {tool}",
runRowPhaseThinking: "Analizando…",
toolbarAwaitingConfirmation: "{count} tarea(s) de AI esperando tu confirmación",
newChat: "Nuevo chat",
thinking: "Procesando...",
reasoningProcess: "Proceso de razonamiento",
+38
View File
@@ -2442,6 +2442,44 @@ export default withEnglishFallback({
welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL",
requestFailed: "Richiesta AI non riuscita",
requestCancelled: "Richiesta AI annullata",
backgroundRunCompleted: "La risposta di AI è stata completata in background",
backgroundRunNeedsConfirmation: "AI ha bisogno della tua conferma per continuare",
recoveredDraftBanner: "È stata recuperata una richiesta in coda prima del riavvio",
recoveredDraftHint: "Puoi modificarla e inviarla di nuovo, oppure rimuoverla.",
discardDraft: "Rimuovi bozza",
runStatusRunning: "In esecuzione",
runStatusWaitingToStart: "In attesa di avvio",
runStatusAwaitingConfirmation: "In attesa di conferma",
runStatusPendingDraft: "Input in attesa",
runStatusCompleted: "Completato",
runStatusFailed: "Non riuscito",
runStatusInterrupted: "Interrotto",
runStatusCancelled: "Annullato",
runUnread: "Ci sono nuovi aggiornamenti in questa conversazione",
closeWithRunsTitle: "Le attività di AI sono ancora in esecuzione",
closeWithRunsDescription: "Uscire da DBX interromperà {count} attività di AI in esecuzione o in coda. Puoi anche chiudere questa finestra e lasciarle terminare.",
closeWithRunsQuit: "Esci e interrompi",
queueSend: "Accoda l'invio",
queueSendHint: "Questa conversazione ha ancora un'attività in esecuzione; il messaggio verrà inviato al termine",
inputQueued: "Messaggio in coda per l'invio dopo l'attività corrente",
queuedInputWaiting: "In attesa — {text}",
queuedInputPending: "In coda per l'invio — {text}",
sendQueuedInput: "Invia messaggio in coda",
queuedInputEdit: "Modifica messaggio in coda",
queuedInputRemove: "Rimuovi messaggio in coda",
rowQueuedInput: "Input in coda",
retryRound: "Riprova questo turno",
deleteConversation: "Elimina conversazione",
deleteActiveConversationTitle: "La conversazione ha un'attività attiva",
deleteActiveConversationDescription: "Questa conversazione ha ancora un'attività in esecuzione o in attesa. Annullare l'attività ed eliminare la conversazione, oppure conservarla?",
cancelTaskAndDelete: "Annulla attività ed elimina conversazione",
keepConversation: "Conserva conversazione",
awayUpdatesDivider: "Aggiornamenti mentre eri via",
jumpToLatest: "Vai all'ultimo",
openPanel: "Apri pannello",
runRowPhaseTool: "Esecuzione di {tool}",
runRowPhaseThinking: "Analisi in corso…",
toolbarAwaitingConfirmation: "{count} attività di AI in attesa della tua conferma",
newChat: "Nuova Chat",
thinking: "Riflessione...",
reasoningProcess: "Processo di riflessione",
+38
View File
@@ -2581,6 +2581,44 @@ export default withEnglishFallback({
welcome: "クエリしたい内容を教えてください。SQLを作成します",
requestFailed: "AIリクエストに失敗しました",
requestCancelled: "AIリクエストがキャンセルされました",
backgroundRunCompleted: "AIの応答はバックグラウンドで完了しました",
backgroundRunNeedsConfirmation: "続行するにはAIの確認が必要です",
recoveredDraftBanner: "再起動前にキューに入っていたリクエストを復元しました",
recoveredDraftHint: "編集して再送するか、削除できます。",
discardDraft: "下書きを削除",
runStatusRunning: "実行中",
runStatusWaitingToStart: "開始待ち",
runStatusAwaitingConfirmation: "確認待ち",
runStatusPendingDraft: "送信待ちの入力",
runStatusCompleted: "完了",
runStatusFailed: "失敗",
runStatusInterrupted: "中断",
runStatusCancelled: "キャンセル済み",
runUnread: "この会話に新しい更新があります",
closeWithRunsTitle: "AIタスクはまだ実行中です",
closeWithRunsDescription: "DBXを終了すると、実行中またはキューに入っている{count}件のAIタスクが中断されます。このダイアログを閉じて完了を待つこともできます。",
closeWithRunsQuit: "終了して中断",
queueSend: "送信をキューに入れる",
queueSendHint: "この会話ではまだタスクが実行中のため、完了後にメッセージが送信されます",
inputQueued: "現在のタスク完了後に送信されるようキューに入れました",
queuedInputWaiting: "待機中 — {text}",
queuedInputPending: "送信待ち — {text}",
sendQueuedInput: "キュー済みメッセージを送信",
queuedInputEdit: "キュー済みメッセージを編集",
queuedInputRemove: "キュー済みメッセージを削除",
rowQueuedInput: "キュー済みの入力",
retryRound: "このラウンドを再試行",
deleteConversation: "会話を削除",
deleteActiveConversationTitle: "会話にアクティブなタスクがあります",
deleteActiveConversationDescription: "この会話には実行中または待機中のタスクがあります。タスクをキャンセルして会話を削除しますか、それとも保持しますか?",
cancelTaskAndDelete: "タスクをキャンセルして会話を削除",
keepConversation: "会話を保持",
awayUpdatesDivider: "離れていた間の更新",
jumpToLatest: "最新へ移動",
openPanel: "パネルを開く",
runRowPhaseTool: "{tool}を実行中",
runRowPhaseThinking: "分析中…",
toolbarAwaitingConfirmation: "{count}件のAIタスクが確認を待っています",
newChat: "新しいチャット",
thinking: "思考中...",
reasoningProcess: "思考プロセス",
+38
View File
@@ -2339,6 +2339,44 @@ export default withEnglishFallback({
welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요",
requestFailed: "AI 요청 실패",
requestCancelled: "AI 요청이 취소되었습니다",
backgroundRunCompleted: "AI 응답이 백그라운드에서 완료되었습니다",
backgroundRunNeedsConfirmation: "계속하려면 AI 확인이 필요합니다",
recoveredDraftBanner: "재시작 전에 대기열에 있던 요청이 복구되었습니다",
recoveredDraftHint: "편집 후 다시 보내거나 삭제할 수 있습니다.",
discardDraft: "초안 삭제",
runStatusRunning: "실행 중",
runStatusWaitingToStart: "시작 대기 중",
runStatusAwaitingConfirmation: "확인 대기 중",
runStatusPendingDraft: "전송 대기 입력",
runStatusCompleted: "완료",
runStatusFailed: "실패",
runStatusInterrupted: "중단됨",
runStatusCancelled: "취소됨",
runUnread: "이 대화에 새 업데이트가 있습니다",
closeWithRunsTitle: "AI 작업이 아직 실행 중입니다",
closeWithRunsDescription: "DBX를 종료하면 실행 중이거나 대기 중인 AI 작업 {count}개가 중단됩니다. 이 대화 상자를 닫고 완료될 때까지 기다릴 수도 있습니다.",
closeWithRunsQuit: "종료하고 중단",
queueSend: "보내기 대기열에 추가",
queueSendHint: "이 대화에는 아직 실행 중인 작업이 있어 메시지가 완료 후 전송됩니다",
inputQueued: "현재 작업 완료 후 보내도록 메시지가 대기열에 추가되었습니다",
queuedInputWaiting: "대기 중 — {text}",
queuedInputPending: "전송 대기 중 — {text}",
sendQueuedInput: "대기 메시지 보내기",
queuedInputEdit: "대기 메시지 편집",
queuedInputRemove: "대기 메시지 제거",
rowQueuedInput: "대기 중인 입력",
retryRound: "이번 라운드 다시 시도",
deleteConversation: "대화 삭제",
deleteActiveConversationTitle: "대화에 활성 작업이 있습니다",
deleteActiveConversationDescription: "이 대화에는 아직 실행 중이거나 대기 중인 작업이 있습니다. 작업을 취소하고 대화를 삭제할까요, 아니면 유지할까요?",
cancelTaskAndDelete: "작업 취소하고 대화 삭제",
keepConversation: "대화 유지",
awayUpdatesDivider: "자리를 비운 동안의 업데이트",
jumpToLatest: "최신으로 이동",
openPanel: "패널 열기",
runRowPhaseTool: "{tool} 실행 중",
runRowPhaseThinking: "분석 중…",
toolbarAwaitingConfirmation: "확인을 기다리는 AI 작업 {count}개",
newChat: "새 채팅",
thinking: "생각하는 중...",
reasoningProcess: "사고 과정",
+38
View File
@@ -2550,6 +2550,44 @@ export default withEnglishFallback({
welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL",
requestFailed: "Falha na solicitação de AI",
requestCancelled: "Solicitação de AI cancelada",
backgroundRunCompleted: "A resposta de AI foi concluída em segundo plano",
backgroundRunNeedsConfirmation: "A AI precisa da sua confirmação para continuar",
recoveredDraftBanner: "Foi recuperada uma solicitação que estava na fila antes da reinicialização",
recoveredDraftHint: "Você pode editá-la e reenviá-la, ou removê-la.",
discardDraft: "Remover rascunho",
runStatusRunning: "Em execução",
runStatusWaitingToStart: "Aguardando para começar",
runStatusAwaitingConfirmation: "Aguardando confirmação",
runStatusPendingDraft: "Entrada pendente",
runStatusCompleted: "Concluído",
runStatusFailed: "Falhou",
runStatusInterrupted: "Interrompido",
runStatusCancelled: "Cancelado",
runUnread: "Há novas atualizações nesta conversa",
closeWithRunsTitle: "As tarefas de AI ainda estão em execução",
closeWithRunsDescription: "Sair do DBX interromperá {count} tarefa(s) de AI em execução ou na fila. Você também pode fechar esta caixa de diálogo e deixá-las terminar.",
closeWithRunsQuit: "Sair e interromper",
queueSend: "Enfileirar envio",
queueSendHint: "Esta conversa ainda tem uma tarefa em execução; a mensagem será enviada quando ela terminar",
inputQueued: "Mensagem enfileirada para enviar após a tarefa atual",
queuedInputWaiting: "Aguardando — {text}",
queuedInputPending: "Na fila para enviar — {text}",
sendQueuedInput: "Enviar mensagem na fila",
queuedInputEdit: "Editar mensagem na fila",
queuedInputRemove: "Remover mensagem na fila",
rowQueuedInput: "Entrada na fila",
retryRound: "Repetir esta rodada",
deleteConversation: "Excluir conversa",
deleteActiveConversationTitle: "A conversa tem uma tarefa ativa",
deleteActiveConversationDescription: "Esta conversa ainda tem uma tarefa em execução ou aguardando. Cancelar a tarefa e excluir a conversa, ou mantê-la?",
cancelTaskAndDelete: "Cancelar tarefa e excluir conversa",
keepConversation: "Manter conversa",
awayUpdatesDivider: "Atualizações enquanto você esteve fora",
jumpToLatest: "Ir para o mais recente",
openPanel: "Abrir painel",
runRowPhaseTool: "Executando {tool}",
runRowPhaseThinking: "Analisando…",
toolbarAwaitingConfirmation: "{count} tarefa(s) de AI aguardando sua confirmação",
newChat: "Nova Conversa",
thinking: "Pensando...",
reasoningProcess: "Processo de raciocínio",
+38
View File
@@ -2438,6 +2438,44 @@ export default withEnglishFallback({
welcome: "聊聊你想查什么,我来写 SQL",
requestFailed: "AI 请求失败",
requestCancelled: "请求已取消",
backgroundRunCompleted: "AI 已在后台完成回复",
backgroundRunNeedsConfirmation: "AI 需要你的确认才能继续",
recoveredDraftBanner: "已恢复上次排队发送的请求",
recoveredDraftHint: "可编辑后重新发送,或删除移除。",
discardDraft: "删除草稿",
runStatusRunning: "运行中",
runStatusWaitingToStart: "等待开始",
runStatusAwaitingConfirmation: "等待写入确认",
runStatusPendingDraft: "待发输入",
runStatusCompleted: "已完成",
runStatusFailed: "失败",
runStatusInterrupted: "已中断",
runStatusCancelled: "已取消",
runUnread: "此会话有新的更新",
closeWithRunsTitle: "AI 任务仍在运行",
closeWithRunsDescription: "退出 DBX 会中断 {count} 个正在运行或排队的 AI 任务。你也可以取消退出并等待任务完成。",
closeWithRunsQuit: "退出并中断任务",
queueSend: "排队发送",
queueSendHint: "当前会话还有任务在运行,发送的消息将在任务完成后自动发出",
inputQueued: "已排队,当前任务完成后自动发送",
queuedInputWaiting: "等待中 — {text}",
queuedInputPending: "已排队待发送 — {text}",
sendQueuedInput: "发送排队消息",
queuedInputEdit: "编辑排队消息",
queuedInputRemove: "移除排队消息",
rowQueuedInput: "有排队输入",
retryRound: "重试本轮",
deleteConversation: "删除会话",
deleteActiveConversationTitle: "会话中还有活动任务",
deleteActiveConversationDescription: "该会话仍有任务在运行或等待。是取消任务并删除会话,还是保留会话?",
cancelTaskAndDelete: "取消任务并删除会话",
keepConversation: "保留会话",
awayUpdatesDivider: "离开期间的更新",
jumpToLatest: "跳到最新",
openPanel: "打开面板",
runRowPhaseTool: "正在执行 {tool}",
runRowPhaseThinking: "正在分析…",
toolbarAwaitingConfirmation: "{count} 个 AI 任务等待你的确认",
newChat: "新对话",
thinking: "思考中...",
reasoningProcess: "思考过程",
+38
View File
@@ -2442,6 +2442,44 @@ export default withEnglishFallback({
welcome: "告訴我你想查詢什麼,我會寫出 SQL",
requestFailed: "AI 請求失敗",
requestCancelled: "AI 請求已取消",
backgroundRunCompleted: "AI 已在背景完成回覆",
backgroundRunNeedsConfirmation: "AI 需要你的確認才能繼續",
recoveredDraftBanner: "已還原上次排隊傳送的請求",
recoveredDraftHint: "可編輯後重新傳送,或刪除移除。",
discardDraft: "刪除草稿",
runStatusRunning: "執行中",
runStatusWaitingToStart: "等待開始",
runStatusAwaitingConfirmation: "等待寫入確認",
runStatusPendingDraft: "待傳輸輸入",
runStatusCompleted: "已完成",
runStatusFailed: "失敗",
runStatusInterrupted: "已中斷",
runStatusCancelled: "已取消",
runUnread: "此會話有新的更新",
closeWithRunsTitle: "AI 任務仍在執行",
closeWithRunsDescription: "結束 DBX 會中斷 {count} 個正在執行或排隊的 AI 任務。你也可以取消結束並等待任務完成。",
closeWithRunsQuit: "結束並中斷任務",
queueSend: "排隊發送",
queueSendHint: "目前會話仍有任務在執行,發送的訊息將在任務完成後自動送出",
inputQueued: "已排隊,目前任務完成後自動發送",
queuedInputWaiting: "等待中 — {text}",
queuedInputPending: "已排隊待發送 — {text}",
sendQueuedInput: "發送排隊訊息",
queuedInputEdit: "編輯排隊訊息",
queuedInputRemove: "移除排隊訊息",
rowQueuedInput: "有排隊輸入",
retryRound: "重試本輪",
deleteConversation: "刪除會話",
deleteActiveConversationTitle: "會話中還有執行中任務",
deleteActiveConversationDescription: "此會話仍有任務在執行或等待。要取消任務並刪除會話,還是保留會話?",
cancelTaskAndDelete: "取消任務並刪除會話",
keepConversation: "保留會話",
awayUpdatesDivider: "離開期間的更新",
jumpToLatest: "跳到最新",
openPanel: "開啟面板",
runRowPhaseTool: "正在執行 {tool}",
runRowPhaseThinking: "正在分析…",
toolbarAwaitingConfirmation: "{count} 個 AI 任務等待你的確認",
newChat: "新對話",
thinking: "思考中……",
reasoningProcess: "思考過程",
@@ -119,8 +119,10 @@ describe("Windows 7 fixed WebView2 runtime bundle", () => {
it("passes the configured fixed-runtime folder to WebView2 discovery and creation", () => {
expect(workspaceCargoToml).toContain('wry = { path = "vendor/wry" }');
expect(wryWebView2Source).toContain('std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER")');
expect(wryWebView2Source).toContain(`CreateCoreWebView2EnvironmentWithOptions(
browser_executable_folder_ptr,`);
// The webview2_com crate indents this with 8 spaces; assert on the two
// adjacent lines loosely so a formatter's re-indent doesn't break the test.
expect(wryWebView2Source).toContain("CreateCoreWebView2EnvironmentWithOptions(");
expect(wryWebView2Source).toContain("browser_executable_folder_ptr,");
expect(wryWebView2Source).toContain("GetAvailableCoreWebView2BrowserVersionString(browser_executable_folder_ptr, &mut versioninfo)");
});
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it } from "vitest";
import {
acquireDesktopAiRunSlot,
activeDesktopAiRuns,
blockingDesktopAiRunsForQuit,
bumpDesktopAiRunSeq,
cancelQueuedDesktopAiRun,
desktopAiRun,
finishDesktopAiRun,
hasActiveDesktopAiRun,
isTerminalDesktopAiRunStatus,
registerDesktopAiRun,
releaseDesktopAiRunSlot,
resetDesktopAiRunRegistryForTests,
} from "@/lib/ai/desktopAiRunRegistry";
import { resolveAiRuntimeStrategy, supportsBackgroundAiRuns } from "@/lib/ai/aiRuntimeStrategy";
describe("AI runtime platform strategy", () => {
afterEach(() => resetDesktopAiRunRegistryForTests());
it("enables detached runs only for the Tauri desktop runtime", () => {
expect(resolveAiRuntimeStrategy(true)).toBe("desktop-background");
expect(supportsBackgroundAiRuns("desktop-background")).toBe(true);
expect(resolveAiRuntimeStrategy(false)).toBe("web-cancel-on-navigation");
expect(supportsBackgroundAiRuns("web-cancel-on-navigation")).toBe(false);
});
it("keeps a desktop run addressable by conversation until terminal", () => {
const run = registerDesktopAiRun({
runId: "run-1",
conversationId: "conversation-1",
sessionIds: ["session-1"],
currentSessionId: "session-1",
status: "running",
messages: [{ content: "partial" }],
assistantMessageIndex: 0,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([run]);
finishDesktopAiRun(run, "completed");
expect(desktopAiRun("conversation-1")?.status).toBe("completed");
expect(activeDesktopAiRuns()).toEqual([]);
});
it("admits at most three runs and lets a queued run be cancelled", async () => {
const makeRun = (index: number) =>
registerDesktopAiRun({
runId: `run-${index}`,
conversationId: `conversation-${index}`,
sessionIds: [],
currentSessionId: "",
status: "preparing" as const,
messages: [],
assistantMessageIndex: -1,
connectionId: `connection-${index}`,
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const runs = [1, 2, 3, 4].map(makeRun);
await Promise.all(runs.slice(0, 3).map(acquireDesktopAiRunSlot));
const fourthAdmission = acquireDesktopAiRunSlot(runs[3]);
expect(runs[3].status).toBe("queued");
expect(cancelQueuedDesktopAiRun(runs[3])).toBe(true);
await expect(fourthAdmission).resolves.toBe(false);
runs.slice(0, 3).forEach((run) => releaseDesktopAiRunSlot(run.runId));
});
it("keeps a recovered pending-input run addressable without consuming a slot or blocking quit", async () => {
const run = registerDesktopAiRun({
runId: "run-recovered",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "pending_recoverable",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
pendingInput: "select * from orders",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Addressable (so the row can render "pending input"), but neither an
// active run nor a quit-blocking one.
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([]);
expect(blockingDesktopAiRunsForQuit()).toEqual([]);
// It also does not consume a global concurrency slot: a fresh preparing run
// is admitted immediately even though the recovered run is registered.
const fresh = registerDesktopAiRun({
runId: "run-fresh",
conversationId: "conversation-2",
sessionIds: [],
currentSessionId: "",
status: "preparing",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-2",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const admission = acquireDesktopAiRunSlot(fresh);
expect(fresh.status).toBe("preparing");
await expect(admission).resolves.toBe(true);
releaseDesktopAiRunSlot(fresh.runId);
});
it("assigns strictly increasing event seqs that persist on the run", () => {
const run = registerDesktopAiRun({
runId: "run-seq",
conversationId: "conversation-1",
sessionIds: ["session-1", "session-2"],
currentSessionId: "session-2",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Parent PRD §8: seq starts at 1 and strictly increases across sessions.
expect(bumpDesktopAiRunSeq(run)).toBe(1);
expect(bumpDesktopAiRunSeq(run)).toBe(2);
expect(run.maxSeq).toBe(2);
// A recovered run with a persisted maxSeq continues from there.
run.maxSeq = 40;
expect(bumpDesktopAiRunSeq(run)).toBe(41);
expect(desktopAiRun("conversation-1")?.maxSeq).toBe(41);
});
it("treats every non-terminal status as active for queue-send, including pending_recoverable", () => {
const statuses = ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const;
for (const status of statuses) {
registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(true);
}
for (const status of ["completed", "failed", "cancelled", "interrupted"] as const) {
const run = registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(false);
finishDesktopAiRun(run, "completed");
}
});
it("classifies terminal statuses for the stop-wait logic (isTerminalDesktopAiRunStatus)", () => {
for (const status of ["completed", "failed", "cancelled"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(true);
}
// `interrupted` is a persisted-row-only pseudo status, never a runtime one.
for (const status of ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(false);
}
});
});
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDesktopAiRunSnapshotScheduler } from "@/lib/ai/desktopAiRunSnapshotScheduler";
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
function makeRun(overrides: Partial<DesktopAiRunRuntime> = {}): DesktopAiRunRuntime {
return {
runId: "run-1",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
...overrides,
};
}
// Finding 1 regression coverage: detached streaming deltas used to live only
// in memory (a crash/quit mid-response lost everything after the last
// pre-stream snapshot). The scheduler persists snapshots incrementally from
// the delta flush path - throttled to keep writes cheap, serialized per run so
// a slow write can never let an older snapshot overwrite a newer one.
describe("desktop AI run snapshot scheduler", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("throttles streaming flushes to one durable save per interval, capturing state at fire time", async () => {
const persistedContentLengths: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persistedContentLengths.push(run.messages.length);
},
intervalMs: 2000,
});
run.messages.push({ content: "a" });
scheduler.schedule(run);
// Further flushes inside the window must not stack extra timers.
run.messages.push({ content: "b" });
scheduler.schedule(run);
run.messages.push({ content: "c" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(1999);
expect(persistedContentLengths).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
// The save reads the run's state when the timer fires (3 messages), not at
// schedule time - so a crash after the first flush still persists the tail.
expect(persistedContentLengths).toEqual([3]);
});
it("re-arms after each save so a long stream keeps snapshotting", async () => {
const persisted: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persisted.push(run.messages.length);
},
intervalMs: 2000,
});
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0]);
run.messages.push({ content: "late" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0, 1]);
});
it("serializes saves so an older in-flight snapshot can never overwrite a newer one", async () => {
const callOrder: number[] = [];
let resolveFirst!: () => void;
const persist = vi
.fn()
.mockImplementationOnce(() => {
callOrder.push(1);
return new Promise<void>((resolve) => {
resolveFirst = resolve;
});
})
.mockImplementationOnce(() => {
callOrder.push(2);
return Promise.resolve();
});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
const first = scheduler.save(run);
run.status = "completed";
const second = scheduler.save(run);
// The newer save must not start (let alone finish) before the older one
// resolves - saves are INSERT OR REPLACEs keyed by the same run row.
await vi.advanceTimersByTimeAsync(10_000);
expect(persist).toHaveBeenCalledTimes(1);
resolveFirst();
await Promise.all([first, second]);
expect(callOrder).toEqual([1, 2]);
});
it("skips a queued save if the run is deleted while a prior write is still in flight", async () => {
// Reviewed finding (delete-resurrection race): a save() that was enqueued
// behind a slow in-flight write was NOT re-checked for discardOnFinish at
// execution time. The delete path commits its DELETE first, so the queued
// save firing afterwards recreated the just-deleted conversation via
// INSERT OR REPLACE. The discard marker must be checked at the actual
// execution point, not just at enqueue time.
const persist = vi.fn(async () => {});
let resolveFirst!: () => void;
persist.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
);
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
// First save: flush the microtask so the write actually starts (and blocks
// on the pending mock) BEFORE the second is enqueued behind it.
const first = scheduler.save(run);
await Promise.resolve();
const second = scheduler.save(run);
// The user deletes the conversation while the first write is still running.
run.discardOnFinish = true;
resolveFirst();
await Promise.all([first, second]);
// Only the already-executing first write persisted; the queued one skipped.
expect(persist).toHaveBeenCalledTimes(1);
});
it("never persists a run the user deleted (discardOnFinish), even through the ordered path", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
scheduler.schedule(makeRun({ discardOnFinish: true }));
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// A run deleted after scheduling must be skipped when the timer fires.
const run = makeRun();
scheduler.schedule(run);
run.discardOnFinish = true;
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// save() on a discarded run is also a no-op (delete-resurrection guard).
await scheduler.save(makeRun({ discardOnFinish: true }));
expect(persist).not.toHaveBeenCalled();
});
it("cancel() drops a pending throttled save so a late fire cannot write stale state", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
scheduler.schedule(run);
scheduler.cancel(run.runId);
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export type AiRuntimeStrategy = "desktop-background" | "web-cancel-on-navigation";
/**
* Keep the platform boundary in one place. Desktop owns a process-wide Tauri
* event stream, while Web's POST/SSE response is still tied to the page request.
*/
export function resolveAiRuntimeStrategy(tauriRuntime = isTauriRuntime()): AiRuntimeStrategy {
return tauriRuntime ? "desktop-background" : "web-cancel-on-navigation";
}
export function supportsBackgroundAiRuns(strategy = resolveAiRuntimeStrategy()): boolean {
return strategy === "desktop-background";
}
@@ -0,0 +1,159 @@
import { shallowReactive } from "vue";
export type DesktopAiRunStatus = "preparing" | "queued" | "running" | "awaiting_write_confirmation" | "completed" | "failed" | "cancelled" | "pending_recoverable";
export type DesktopAiRunFifoCategory = "normal_send" | "write_confirmation_resume";
const DESKTOP_AI_CONCURRENCY_LIMIT = 3;
export interface DesktopAiRunRuntime<TMessage = unknown> {
runId: string;
conversationId: string;
sessionIds: string[];
currentSessionId: string;
status: DesktopAiRunStatus;
messages: TMessage[];
assistantMessageIndex: number;
connectionId: string;
connectionName: string;
database: string;
schema?: string;
pendingConfirmation?: unknown;
/** Queue category of a global-FIFO item, persisted so restart recovery can
* tell a normal send (→ editable pending input) from an accepted
* write-confirmation resume (→ back to awaiting confirmation). */
fifoCategory?: DesktopAiRunFifoCategory;
/** User input text carried by a normal-send FIFO item, recovered after
* restart as an editable, unsent pending draft. */
pendingInput?: string;
/** Highest event `seq` assigned to this run, across all its sessions (parent
* PRD §8). Strictly increasing from 1; persisted with the run. */
maxSeq?: number;
/** True when the user rejected the write-confirmation card for this run. A
* run cancelled on this path must NOT auto-send the conversation's queued
* input (parent PRD §5). In-memory only — never serialized. */
pendingConfirmationRejected?: boolean;
createdAt: string;
updatedAt: string;
cancelRequested: boolean;
discardOnFinish?: boolean;
flushPending?: () => void;
/** Resolved by the owning send() pipeline once it has fully settled (its
* finally block, or a pre-stream early return). A stop request waits on this
* signal rather than finalizing the run itself, so a cancellation-pending
* run stays visible - and keeps owning its concurrency slot - until the
* real terminal event (or a bounded force-abandon). Runtime-only - never
* serialized. */
settled?: Promise<void>;
}
// This registry deliberately lives outside AiAssistant.vue. Closing the panel
// unmounts its view in some layouts, but a Desktop run remains addressable until
// it reaches a terminal state. Web never registers runs here.
const runsByConversation = shallowReactive(new Map<string, DesktopAiRunRuntime>());
const admittedRunIds = new Set<string>();
const admissionQueue: Array<{ run: DesktopAiRunRuntime; resolve: (admitted: boolean) => void }> = [];
export function registerDesktopAiRun<TMessage>(run: DesktopAiRunRuntime<TMessage>): DesktopAiRunRuntime<TMessage> {
runsByConversation.set(run.conversationId, run as DesktopAiRunRuntime);
return run;
}
export function desktopAiRun<TMessage = unknown>(conversationId: string): DesktopAiRunRuntime<TMessage> | undefined {
return runsByConversation.get(conversationId) as DesktopAiRunRuntime<TMessage> | undefined;
}
export function updateDesktopAiRun(run: DesktopAiRunRuntime, patch: Partial<DesktopAiRunRuntime>): void {
Object.assign(run, patch, { updatedAt: new Date().toISOString() });
// Values are intentionally shallow (message arrays can be large). Re-setting
// the entry invalidates count/badge computations when status changes.
runsByConversation.set(run.conversationId, run);
}
export function finishDesktopAiRun(run: DesktopAiRunRuntime, status: Extract<DesktopAiRunStatus, "completed" | "failed" | "cancelled">): void {
updateDesktopAiRun(run, { status, currentSessionId: "", flushPending: undefined, pendingConfirmation: undefined });
}
export function acquireDesktopAiRunSlot(run: DesktopAiRunRuntime): Promise<boolean> {
if (admittedRunIds.has(run.runId)) return Promise.resolve(true);
if (admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
admittedRunIds.add(run.runId);
return Promise.resolve(true);
}
updateDesktopAiRun(run, { status: "queued" });
return new Promise((resolve) => admissionQueue.push({ run, resolve }));
}
export function releaseDesktopAiRunSlot(runId: string): void {
if (!admittedRunIds.delete(runId)) return;
while (admissionQueue.length > 0 && admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
const next = admissionQueue.shift()!;
if (next.run.cancelRequested || next.run.status === "cancelled") {
next.resolve(false);
continue;
}
admittedRunIds.add(next.run.runId);
updateDesktopAiRun(next.run, { status: "preparing" });
next.resolve(true);
}
}
export function cancelQueuedDesktopAiRun(run: DesktopAiRunRuntime): boolean {
const index = admissionQueue.findIndex((entry) => entry.run === run);
if (index < 0) return false;
const [entry] = admissionQueue.splice(index, 1);
run.cancelRequested = true;
finishDesktopAiRun(run, "cancelled");
entry.resolve(false);
return true;
}
export function activeDesktopAiRuns(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation");
}
/** Terminal statuses: the run has fully settled and no longer blocks the
* conversation, the quit prompt, or a concurrency slot. `interrupted` is a
* persisted-row-only pseudo status (never a runtime status) and is excluded
* here for that reason. */
export function isTerminalDesktopAiRunStatus(status: DesktopAiRunStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
/** Statuses that block queue-send/auto-send: the conversation already owns a
* run the queued input must wait behind (parent PRD §5). */
export function hasActiveDesktopAiRun(conversationId: string): boolean {
const run = runsByConversation.get(conversationId);
if (!run) return false;
return run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation" || run.status === "pending_recoverable";
}
export function blockingDesktopAiRunsForQuit(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running");
}
/**
* Advances a run's event `seq` (parent PRD §8). Every agent event for the run —
* across all its sessions — takes the next integer, strictly increasing from 1.
* Returns the new seq so the caller can record it as the last-processed anchor.
*/
export function bumpDesktopAiRunSeq(run: DesktopAiRunRuntime): number {
const next = (run.maxSeq ?? 0) + 1;
run.maxSeq = next;
runsByConversation.set(run.conversationId, run);
return next;
}
export function removeDesktopAiRun(conversationId: string): void {
runsByConversation.delete(conversationId);
}
export function retireDesktopAiRun(run: DesktopAiRunRuntime): void {
if (runsByConversation.get(run.conversationId) === run) runsByConversation.delete(run.conversationId);
}
export function resetDesktopAiRunRegistryForTests(): void {
runsByConversation.clear();
admittedRunIds.clear();
while (admissionQueue.length) admissionQueue.shift()!.resolve(false);
}
@@ -0,0 +1,83 @@
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
export interface DesktopAiRunSnapshotSchedulerOptions<TMessage> {
/** Durable save for one run. Runs while the previous save for the same run
* is still in flight are deferred, so call order equals write order.
* Rejections are swallowed by the scheduler - a failed save must never
* break the chain for the saves behind it. */
persist: (run: DesktopAiRunRuntime<TMessage>) => Promise<void>;
/** Minimum spacing between throttled saves for one run. Bounds how much
* streamed output a crash/quit can lose relative to the last durable
* snapshot. */
intervalMs: number;
}
export interface DesktopAiRunSnapshotScheduler<TMessage = unknown> {
/** Schedules a throttled save for a streaming run: at most one save per
* intervalMs, capturing whatever the run's state is when the timer fires
* (not when the flush that scheduled it happened). */
schedule(run: DesktopAiRunRuntime<TMessage>): void;
/** Saves immediately, strictly ordered after any save already queued or in
* flight for the run, so an older snapshot can never overwrite a newer
* one (saves are INSERT OR REPLACEs keyed by the same row). */
save(run: DesktopAiRunRuntime<TMessage>): Promise<void>;
/** Drops a pending throttled save - call when the run settles or is deleted,
* so a late fire cannot write stale state over the authoritative final
* save (or resurrect a deleted conversation). */
cancel(runId: string): void;
}
// Streaming deltas used to live only in memory: a crash or quit mid-response
// lost everything after the last pre-stream snapshot. The scheduler persists
// snapshots incrementally from the delta flush path while keeping the writes
// cheap (throttled) and ordered (serialized per run).
export function createDesktopAiRunSnapshotScheduler<TMessage>(options: DesktopAiRunSnapshotSchedulerOptions<TMessage>): DesktopAiRunSnapshotScheduler<TMessage> {
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
const chains = new Map<string, Promise<void>>();
const enqueue = (run: DesktopAiRunRuntime<TMessage>): Promise<void> => {
const chain = (chains.get(run.runId) ?? Promise.resolve())
.then(() => {
// Re-check discard at the actual execution point, not just at enqueue
// time: the run may have been deleted while an earlier write in this
// chain was still in flight. Without this re-check the queued save
// would persist anyway and resurrect the deleted conversation via
// INSERT OR REPLACE.
if (run.discardOnFinish) return;
return options.persist(run);
})
.catch(() => {})
.finally(() => {
if (chains.get(run.runId) === chain) chains.delete(run.runId);
});
chains.set(run.runId, chain);
return chain;
};
return {
schedule(run) {
// A run the user deleted must never be written back - INSERT OR REPLACE
// would resurrect the just-deleted conversation.
if (run.discardOnFinish) return;
if (pendingTimers.has(run.runId)) return;
const timer = setTimeout(() => {
pendingTimers.delete(run.runId);
if (run.discardOnFinish) return;
void enqueue(run);
}, options.intervalMs);
pendingTimers.set(run.runId, timer);
},
save(run) {
// Same delete-resurrection guard as schedule(): no caller may persist a
// discarded run, not even through the ordered path.
if (run.discardOnFinish) return Promise.resolve();
return enqueue(run);
},
cancel(runId) {
const timer = pendingTimers.get(runId);
if (timer === undefined) return;
clearTimeout(timer);
pendingTimers.delete(runId);
},
};
}
+5
View File
@@ -345,6 +345,9 @@ export const snippetSyncDownload = forward("snippetSyncDownload");
export const saveAiConversation = forward("saveAiConversation");
export const loadAiConversations = forward("loadAiConversations");
export const deleteAiConversation = forward("deleteAiConversation");
export const saveAiRun = forward("saveAiRun");
export const saveAiRunState = forward("saveAiRunState");
export const loadAiRuns = forward("loadAiRuns");
// Prompt Templates
export const loadPromptTemplates = forward("loadPromptTemplates");
@@ -821,6 +824,8 @@ export type {
AiModelInfo,
AiChatMessage,
AiConversation,
AiRun,
AiRunStatus,
PromptTemplate,
AgentDriverInfo,
AgentOfflineArtifactKind,
+36 -12
View File
@@ -60,6 +60,7 @@ import type {
AiCompletionRequest,
AiStreamChunk,
AiConversation,
AiRun,
AiModelInfo,
DriverStoreUsage,
DriverRuntimeSummary,
@@ -1590,13 +1591,16 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (data && data !== "[DONE]") {
let chunk: AiStreamChunk;
try {
const chunk: AiStreamChunk = JSON.parse(data);
onChunk(chunk);
if (chunk.done) return;
chunk = JSON.parse(data);
} catch {
// skip malformed JSON
continue;
}
if (chunk.error) throw new Error(chunk.error);
onChunk(chunk);
if (chunk.done) return;
}
}
}
@@ -1687,18 +1691,23 @@ export async function aiAgentStream(
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (data && data !== "[DONE]") {
let parsed: unknown;
try {
const parsed = JSON.parse(data);
if (!isAgentEvent(parsed)) {
console.warn("[aiAgentStream] Skipping invalid agent event:", data);
continue;
}
onEvent(parsed);
if (parsed.type === "agent_end" || parsed.type === "error") {
result = data;
}
parsed = JSON.parse(data);
} catch {
// skip malformed JSON
continue;
}
if (!isAgentEvent(parsed)) {
console.warn("[aiAgentStream] Skipping invalid agent event:", data);
continue;
}
onEvent(parsed);
if (parsed.type === "error") {
throw new Error(parsed.message);
}
if (parsed.type === "agent_end") {
result = data;
}
}
}
@@ -2067,6 +2076,21 @@ export async function deleteAiConversation(id: string): Promise<void> {
return del(`/api/ai/conversation/${id}`);
}
// Background AI runs are a Desktop-only capability in this release. Keep the
// symbols for backend-module parity without changing Web's request-bound SSE
// lifecycle or adding a partially functional persistence API.
export async function saveAiRun(_run: AiRun): Promise<void> {
throw new Error("Background AI runs are only available in DBX Desktop");
}
export async function saveAiRunState(_conversation: AiConversation, _run: AiRun): Promise<void> {
throw new Error("Background AI runs are only available in DBX Desktop");
}
export async function loadAiRuns(): Promise<AiRun[]> {
return [];
}
// ---------------------------------------------------------------------------
// Prompt Templates
// ---------------------------------------------------------------------------
+40
View File
@@ -447,6 +447,8 @@ export interface AiStreamChunk {
delta: string;
reasoning_delta?: string;
done: boolean;
/** Web-only explicit terminal error; Tauri reports invoke failures directly. */
error?: string;
}
export async function aiStream(sessionId: string, request: AiCompletionRequest, onChunk: (chunk: AiStreamChunk) => void): Promise<void> {
@@ -904,6 +906,32 @@ export interface AiConversation {
connectionName: string;
database: string;
messages: AiChatMessage[];
/** One editable "send later" input saved while an active run occupies the
* conversation (parent PRD §5). Persisted with the conversation. */
queuedInput?: string;
createdAt: string;
updatedAt: string;
}
export type AiRunStatus = "preparing" | "queued" | "running" | "awaiting_write_confirmation" | "completed" | "failed" | "cancelled" | "interrupted" | "pending_recoverable";
export type AiRunFifoCategory = "normal_send" | "write_confirmation_resume";
export interface AiRun {
runId: string;
conversationId: string;
sessionIds: string[];
status: AiRunStatus;
connectionId: string;
database: string;
schema?: string;
pendingConfirmation?: unknown;
fifoCategory?: AiRunFifoCategory;
pendingInput?: string;
/** Highest event seq assigned to this run across all its sessions (parent
* PRD §8). Drives the unread baseline and the "updates while you were away"
* separator anchor. */
maxSeq?: number;
createdAt: string;
updatedAt: string;
}
@@ -920,6 +948,18 @@ export async function deleteAiConversation(id: string): Promise<void> {
return invoke("delete_ai_conversation", { id });
}
export async function saveAiRun(run: AiRun): Promise<void> {
return invoke("save_ai_run", { run });
}
export async function saveAiRunState(conversation: AiConversation, run: AiRun): Promise<void> {
return invoke("save_ai_run_state", { conversation, run });
}
export async function loadAiRuns(): Promise<AiRun[]> {
return invoke("load_ai_runs");
}
// --- Prompt Templates ---
export interface PromptTemplate {