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
@@ -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
+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>");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "思考プロセス",
|
||||
|
||||
@@ -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: "사고 과정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "思考过程",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user