feat(ai): run agent sessions as background tasks

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

No files matched your search

+6 -2
View File
@@ -6,12 +6,16 @@ import { dirname, join, resolve } from "node:path";
const LOCALES_DIR = "apps/desktop/src/i18n/locales"; const LOCALES_DIR = "apps/desktop/src/i18n/locales";
const SOURCE_LOCALE = "zh-CN"; const SOURCE_LOCALE = "zh-CN";
const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"]; const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-TW"];
// `git show <ref>:<path>` needs forward slashes even on Windows, where
// `path.join()` yields backslashes. Allow an override (CI keeps the default).
const refPath = (path) => path.replaceAll("\\", "/");
const TARGET_LABELS = { const TARGET_LABELS = {
en: "English", en: "English",
es: "Spanish", es: "Spanish",
it: "Italian", it: "Italian",
ja: "Japanese", ja: "Japanese",
ko: "Korean",
"pt-BR": "Brazilian Portuguese", "pt-BR": "Brazilian Portuguese",
"zh-TW": "Traditional Chinese used in Taiwan", "zh-TW": "Traditional Chinese used in Taiwan",
}; };
@@ -106,7 +110,7 @@ function localePath(locale) {
function readBaseFile(path) { function readBaseFile(path) {
try { try {
return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); return execFileSync("git", ["show", `${baseRef}:${refPath(path)}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
} catch (error) { } catch (error) {
throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`); throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`);
} }
+5
View File
@@ -62,6 +62,11 @@ Thumbs.db
# Temp # Temp
tmp/ tmp/
# AI background-run dev artifacts (never commit)
.dlp-quarantine/
outputs/
target-ai-write-confirmation/
# Code review tool artifacts # Code review tool artifacts
review_diff.json review_diff.json
review_classified.json review_classified.json
+47 -3
View File
@@ -66,6 +66,7 @@ import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPa
import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database"; import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
import { import {
isBrowserReloadShortcut, isBrowserReloadShortcut,
isCloseOtherTabsShortcut, isCloseOtherTabsShortcut,
@@ -143,6 +144,7 @@ const DriverStorePage = defineAsyncComponent(() => import("@/components/config/D
const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue")); const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue"));
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue")); const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.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 LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue")); const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue"));
const TabSwitcherDialog = defineAsyncComponent(() => import("@/components/tabs/TabSwitcherDialog.vue")); const TabSwitcherDialog = defineAsyncComponent(() => import("@/components/tabs/TabSwitcherDialog.vue"));
@@ -154,6 +156,8 @@ type AiAssistantHandle = {
setPrompt: (text: string) => void; setPrompt: (text: string) => void;
addTableMention: (target: { schema?: string; table: string }) => void; addTableMention: (target: { schema?: string; table: string }) => void;
clearContextReferences: () => void; clearContextReferences: () => void;
/** Opens a conversation by id (used by the background-run toast, §9). */
selectConversationById: (conversationId: string) => void;
}; };
const { t } = useI18n(); const { t } = useI18n();
@@ -180,7 +184,7 @@ connectionStore.setBeforeConnectHandler(async (config) => {
await connectionStore.updateConnection(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 { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
const { activeCount: activeBackgroundTaskCount } = useExportTracker(); const { activeCount: activeBackgroundTaskCount } = useExportTracker();
const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs)); const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs));
@@ -211,6 +215,10 @@ const {
const { setupFileDrop } = useFileDrop(); const { setupFileDrop } = useFileDrop();
const isDesktop = isTauriRuntime(); 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({ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged } = useMcpUpdateBadge({
isDesktop, isDesktop,
updateNotificationsEnabled: () => settingsStore.editorSettings.updateNotificationsEnabled, updateNotificationsEnabled: () => settingsStore.editorSettings.updateNotificationsEnabled,
@@ -307,6 +315,9 @@ const pendingPrevActiveTabId = ref<string | null>(null);
const pendingSaveShouldCloseTab = ref(true); const pendingSaveShouldCloseTab = ref(true);
const pendingAppCloseAction = ref<AppCloseAction | null>(null); const pendingAppCloseAction = ref<AppCloseAction | null>(null);
const pendingCloseActionChoice = ref(false); 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 activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
const tabNavigationHistory = ref(createTabNavigationHistory()); const tabNavigationHistory = ref(createTabNavigationHistory());
@@ -953,6 +964,16 @@ function openAiPanel() {
openRightSidebarPanel("ai"); 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) { function analyzeHistoryWithAi(entry: HistoryEntry) {
const connectionId = entry.connection_id || activeTab.value?.connectionId; const connectionId = entry.connection_id || activeTab.value?.connectionId;
if (!connectionId) { if (!connectionId) {
@@ -1088,10 +1109,18 @@ function cancelPendingSaveAndClose() {
function cancelPendingAppClose() { function cancelPendingAppClose() {
pendingAppCloseAction.value = null; pendingAppCloseAction.value = null;
pendingCloseActionChoice.value = false; pendingCloseActionChoice.value = false;
showAiRunsClosePrompt.value = false;
aiRunsQuitConfirmed = false;
pendingSaveShouldCloseTab.value = true; pendingSaveShouldCloseTab.value = true;
} }
async function finishPendingAppClose(action: AppCloseAction) { async function finishPendingAppClose(action: AppCloseAction) {
if (action === "quit" && !aiRunsQuitConfirmed && blockingAiRunCount.value > 0) {
pendingAppCloseAction.value = action;
showAiRunsClosePrompt.value = true;
return;
}
aiRunsQuitConfirmed = false;
if (pendingCloseActionChoice.value) { if (pendingCloseActionChoice.value) {
pendingCloseActionChoice.value = false; pendingCloseActionChoice.value = false;
showCloseActionPrompt.value = true; showCloseActionPrompt.value = true;
@@ -1120,6 +1149,13 @@ async function finishPendingAppClose(action: AppCloseAction) {
await performCloseAction(action); await performCloseAction(action);
} }
function confirmQuitWithActiveAiRuns() {
const action = pendingAppCloseAction.value ?? "quit";
showAiRunsClosePrompt.value = false;
aiRunsQuitConfirmed = true;
void finishPendingAppClose(action);
}
function continuePendingAppCloseAfterSave() { function continuePendingAppCloseAfterSave() {
const action = pendingAppCloseAction.value; const action = pendingAppCloseAction.value;
if (!action) return; if (!action) return;
@@ -2811,6 +2847,7 @@ onMounted(async () => {
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
window.addEventListener("dbx:activate-query-surface", activateQuerySurface); window.addEventListener("dbx:activate-query-surface", activateQuerySurface);
window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged);
window.addEventListener("dbx:ai-run-notify", handleAiRunNotify);
if (isDesktop) { if (isDesktop) {
document.addEventListener("contextmenu", handleContextMenu); document.addEventListener("contextmenu", handleContextMenu);
} }
@@ -2886,6 +2923,7 @@ onUnmounted(() => {
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent); window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
window.removeEventListener("dbx:activate-query-surface", activateQuerySurface); window.removeEventListener("dbx:activate-query-surface", activateQuerySurface);
window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged);
window.removeEventListener("dbx:ai-run-notify", handleAiRunNotify);
document.removeEventListener("contextmenu", handleContextMenu); document.removeEventListener("contextmenu", handleContextMenu);
window.clearTimeout(sqlLibraryFlyAnimationTimer); window.clearTimeout(sqlLibraryFlyAnimationTimer);
}); });
@@ -2900,6 +2938,8 @@ onUnmounted(() => {
:is-dark="isDark" :is-dark="isDark"
:theme-mode="themeMode" :theme-mode="themeMode"
:show-ai-panel="showAiPanel" :show-ai-panel="showAiPanel"
:active-ai-run-count="activeAiRunCount"
:awaiting-ai-run-count="awaitingAiRunCount"
:show-history="showHistory" :show-history="showHistory"
:show-sql-library="showSqlLibraryPanel" :show-sql-library="showSqlLibraryPanel"
:sql-library-save-feedback-id="sqlLibrarySaveFeedbackId" :sql-library-save-feedback-id="sqlLibrarySaveFeedbackId"
@@ -3241,6 +3281,7 @@ onUnmounted(() => {
/> />
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" /> <ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" /> <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" /> <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" /> <TabSwitcherDialog :open="showTabSwitcher" :tabs="tabSwitcherTabs" :selected-index="tabSwitcherIndex" :shortcut-hint="tabSwitcherShortcutHint" @update:open="handleTabSwitcherOpenChange" @update:selected-index="tabSwitcherIndex = $event" @select="handleTabSwitcherSelect" />
</div> </div>
@@ -3259,8 +3300,11 @@ onUnmounted(() => {
@animationend="finishSqlLibraryFlyAnimation(sqlLibraryFlyAnimation.id)" @animationend="finishSqlLibraryFlyAnimation(sqlLibraryFlyAnimation.id)"
/> />
<Transition name="toast"> <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"> <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">
{{ toastMessage }} <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> </div>
</Transition> </Transition>
</Teleport> </Teleport>
File diff suppressed because it is too large. Load diff
@@ -33,7 +33,7 @@ function bodyOf(fnSignature: string): string {
throw new Error(`unbalanced braces reading body of "${fnSignature}"`); 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", () => { it("abandonInFlightRequest() invalidates the generation guard before/regardless of the backend RPC", () => {
const body = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)"); const body = bodyOf("function abandonInFlightRequest(alreadyCancelledSessionId?: string)");
expect(body).toContain("aiGenerationGuard.invalidate();"); 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) {")); 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()"); 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. // 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)"); const body = bodyOf("function selectConversation(conv: AiConversation)");
expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();"); expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
expect(body.indexOf("if (isGenerating.value) abandonInFlightRequest();")).toBeLessThan(body.indexOf("conversationId.value = conv.id;")); 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", () => { 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,"); 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();"); 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("await deleteConversationWithCancellation({");
expect(deleteBody).toContain("abandon: () => abandonInFlightRequest()"); expect(deleteBody).toContain("abandon: () => abandonInFlightRequest()");
expect(deleteBody).toContain("deletePersisted: () => deleteAiConversation(id).catch(() => {})"); expect(deleteBody).toContain("deletePersisted: () => deleteAiConversation(id).catch(() => {})");
expect(deleteBody).toContain("if (conversationId.value === id) clearMessages();"); 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", () => { 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 sendBody = bodyOf("async function send()");
const claimIdx = sendBody.indexOf("const myGeneration = aiGenerationGuard.begin();"); 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 // anything that touches messages/mentions belonging to whatever conversation
// is current by the time it resolves. // is current by the time it resolves.
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()"); 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(ensureLoadedAwaitIdx).toBeGreaterThan(claimIdx);
expect(recheckIdx).toBeGreaterThan(ensureLoadedAwaitIdx); 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 sendBody = bodyOf("async function send()");
const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()"); const ensureLoadedAwaitIdx = sendBody.indexOf("await promptTemplateStore.ensureLoaded()");
const grantConsumedIdx = sendBody.indexOf("const allowWriteSql = requestedMode", ensureLoadedAwaitIdx); 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).toBeGreaterThan(ensureLoadedAwaitIdx);
expect(recheckIdx).toBeLessThan(grantConsumedIdx); expect(recheckIdx).toBeLessThan(grantConsumedIdx);
const recheckEnd = sendBody.indexOf("}", recheckIdx); 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 sendBody = bodyOf("async function send()");
const callbackStart = sendBody.indexOf("(event: AgentEvent) => {"); const callbackStart = sendBody.indexOf("(event: AgentEvent) => {");
expect(callbackStart).toBeGreaterThanOrEqual(0); 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); const pushIdx = sendBody.indexOf("agentEvents.push(event);", callbackStart);
expect(guardIdx).toBeGreaterThan(callbackStart); expect(guardIdx).toBeGreaterThan(callbackStart);
expect(guardIdx).toBeLessThan(pushIdx); 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 // either throw (silently eating the real error) or corrupt a different
// conversation's transcript. // conversation's transcript.
expect(catchBody).not.toContain("messages.value[assistantIdx].content ="); expect(catchBody).not.toContain("messages.value[assistantIdx].content =");
expect(catchBody).toContain("aiGenerationGuard.isCurrent(myGeneration)"); expect(catchBody).toContain("generationCanContinue()");
expect(catchBody).toContain("const msg = messages.value[assistantIdx];"); expect(catchBody).toContain("const msg = runMessages[assistantIdx];");
expect(catchBody).toContain("if (msg) msg.content ="); 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 {"); const start = sendBody.indexOf("} finally {");
expect(start).toBeGreaterThanOrEqual(0); expect(start).toBeGreaterThanOrEqual(0);
const finallyBody = sendBody.slice(start); 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); expect(guardIdx).toBeGreaterThanOrEqual(0);
const isGeneratingIdx = finallyBody.indexOf("isGenerating.value = false;"); const isGeneratingIdx = finallyBody.indexOf("isGenerating.value = false;");
const sessionResetIdx = finallyBody.indexOf('currentSessionId.value = "";'); 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", () => { it("send() rechecks the generation after EACH context-preparation await, before ever starting the stream", () => {
const sendBody = bodyOf("async function send()"); const sendBody = bodyOf("async function send()");
const sqlFilesIdx = sendBody.indexOf("await loadReferencedSqlFiles("); 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 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); const runIdx = sendBody.indexOf("await runAgentStream(", contextIdx);
// A clear/switch/unmount firing during EITHER await invalidates the generation // A clear/switch/unmount firing during EITHER await invalidates the generation
// but doesn't touch the backend (no session registered yet) — without a // 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 // "finalizes the stuck placeholder message" test above). Must be set at the
// same point currentSessionId is, and cleared alongside it too. // same point currentSessionId is, and cleared alongside it too.
const sendBody = bodyOf("async function send()"); const sendBody = bodyOf("async function send()");
const pushIdx = sendBody.indexOf('messages.value.push({ role: "assistant"'); const pushIdx = sendBody.indexOf('runMessages.push({ role: "assistant"');
const assistantIdxIdx = sendBody.indexOf("const assistantIdx = messages.value.length - 1;", pushIdx); const assistantIdxIdx = sendBody.indexOf("const assistantIdx = runMessages.length - 1;", pushIdx);
const trackIdx = sendBody.indexOf("currentAssistantMessageIndex = assistantIdx;", assistantIdxIdx); const trackIdx = sendBody.indexOf("currentAssistantMessageIndex = assistantIdx;", assistantIdxIdx);
const sessionSetIdx = sendBody.indexOf("currentSessionId.value = sessionId;", trackIdx); const sessionSetIdx = sendBody.indexOf("currentSessionId.value = sessionId;", trackIdx);
expect(pushIdx).toBeGreaterThanOrEqual(0); 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 // callback/catch/finally would keep writing into refs this now-unmounted
// instance's closures still hold. Must go through the same invalidating path as // instance's closures still hold. Must go through the same invalidating path as
// clearMessages()/selectConversation(). // clearMessages()/selectConversation().
expect(body).toContain("if (isGenerating.value) abandonInFlightRequest();"); expect(body).toContain("if (isGenerating.value && !backgroundAiRunsEnabled) abandonInFlightRequest();");
expect(body).not.toContain("cancelStream();"); 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", () => { 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 — // 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 // 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); const statusBlock = source.slice(statusLineIdx);
expect(statusBlock).toContain("<Hourglass v-else"); 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; isDark: boolean;
themeMode: AppThemeMode; themeMode: AppThemeMode;
showAiPanel: boolean; 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; showHistory: boolean;
showSqlLibrary: boolean; showSqlLibrary: boolean;
sqlLibrarySaveFeedbackId: number; sqlLibrarySaveFeedbackId: number;
@@ -642,8 +646,19 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.ai"> <Tooltip v-if="toolbarItems.ai">
<TooltipTrigger as-child> <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" /> <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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>AI</TooltipContent> <TooltipContent>AI</TooltipContent>
+13 -2
View File
@@ -1,9 +1,12 @@
import { ref, type Ref } from "vue"; import { ref, type Ref } from "vue";
export type ToastAction = { label: string; onClick: () => void };
type ToastState = { type ToastState = {
message: Ref<string>; message: Ref<string>;
visible: Ref<boolean>; visible: Ref<boolean>;
timer: number; timer: number;
action: Ref<ToastAction | undefined>;
}; };
declare global { declare global {
@@ -16,17 +19,25 @@ const toastState =
message: ref(""), message: ref(""),
visible: ref(false), visible: ref(false),
timer: 0, timer: 0,
action: ref<ToastAction | undefined>(undefined),
}); });
export function useToast() { export function useToast() {
function toast(msg: string, duration = 2000) { function toast(msg: string, duration = 2000, action?: ToastAction) {
toastState.message.value = msg; toastState.message.value = msg;
toastState.action.value = action;
toastState.visible.value = true; toastState.visible.value = true;
clearTimeout(toastState.timer); clearTimeout(toastState.timer);
toastState.timer = window.setTimeout(() => { toastState.timer = window.setTimeout(() => {
toastState.visible.value = false; toastState.visible.value = false;
toastState.action.value = undefined;
}, duration); }, duration);
} }
return { message: toastState.message, visible: toastState.visible, toast }; function dismissToast() {
toastState.visible.value = false;
toastState.action.value = undefined;
}
return { message: toastState.message, visible: toastState.visible, action: toastState.action, toast, dismissToast };
} }
+38
View File
@@ -2514,6 +2514,44 @@ export default {
welcome: "Tell me what you'd like to query, and I'll write the SQL", welcome: "Tell me what you'd like to query, and I'll write the SQL",
requestFailed: "AI request failed", requestFailed: "AI request failed",
requestCancelled: "Request cancelled", 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", newChat: "New Chat",
thinking: "Thinking...", thinking: "Thinking...",
reasoningProcess: "Thinking process", reasoningProcess: "Thinking process",
+38
View File
@@ -2548,6 +2548,44 @@ export default withEnglishFallback({
welcome: "Dime qué quieres consultar y escribiré el SQL", welcome: "Dime qué quieres consultar y escribiré el SQL",
requestFailed: "La solicitud de AI falló", requestFailed: "La solicitud de AI falló",
requestCancelled: "Solicitud de AI cancelada", 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", newChat: "Nuevo chat",
thinking: "Procesando...", thinking: "Procesando...",
reasoningProcess: "Proceso de razonamiento", reasoningProcess: "Proceso de razonamiento",
+38
View File
@@ -2442,6 +2442,44 @@ export default withEnglishFallback({
welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL", welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL",
requestFailed: "Richiesta AI non riuscita", requestFailed: "Richiesta AI non riuscita",
requestCancelled: "Richiesta AI annullata", 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", newChat: "Nuova Chat",
thinking: "Riflessione...", thinking: "Riflessione...",
reasoningProcess: "Processo di riflessione", reasoningProcess: "Processo di riflessione",
+38
View File
@@ -2581,6 +2581,44 @@ export default withEnglishFallback({
welcome: "クエリしたい内容を教えてください。SQLを作成します", welcome: "クエリしたい内容を教えてください。SQLを作成します",
requestFailed: "AIリクエストに失敗しました", requestFailed: "AIリクエストに失敗しました",
requestCancelled: "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: "新しいチャット", newChat: "新しいチャット",
thinking: "思考中...", thinking: "思考中...",
reasoningProcess: "思考プロセス", reasoningProcess: "思考プロセス",
+38
View File
@@ -2339,6 +2339,44 @@ export default withEnglishFallback({
welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요", welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요",
requestFailed: "AI 요청 실패", requestFailed: "AI 요청 실패",
requestCancelled: "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: "새 채팅", newChat: "새 채팅",
thinking: "생각하는 중...", thinking: "생각하는 중...",
reasoningProcess: "사고 과정", reasoningProcess: "사고 과정",
+38
View File
@@ -2550,6 +2550,44 @@ export default withEnglishFallback({
welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL", welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL",
requestFailed: "Falha na solicitação de AI", requestFailed: "Falha na solicitação de AI",
requestCancelled: "Solicitação de AI cancelada", 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", newChat: "Nova Conversa",
thinking: "Pensando...", thinking: "Pensando...",
reasoningProcess: "Processo de raciocínio", reasoningProcess: "Processo de raciocínio",
+38
View File
@@ -2438,6 +2438,44 @@ export default withEnglishFallback({
welcome: "聊聊你想查什么,我来写 SQL", welcome: "聊聊你想查什么,我来写 SQL",
requestFailed: "AI 请求失败", requestFailed: "AI 请求失败",
requestCancelled: "请求已取消", 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: "新对话", newChat: "新对话",
thinking: "思考中...", thinking: "思考中...",
reasoningProcess: "思考过程", reasoningProcess: "思考过程",
+38
View File
@@ -2442,6 +2442,44 @@ export default withEnglishFallback({
welcome: "告訴我你想查詢什麼,我會寫出 SQL", welcome: "告訴我你想查詢什麼,我會寫出 SQL",
requestFailed: "AI 請求失敗", requestFailed: "AI 請求失敗",
requestCancelled: "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: "新對話", newChat: "新對話",
thinking: "思考中……", thinking: "思考中……",
reasoningProcess: "思考過程", reasoningProcess: "思考過程",
@@ -119,8 +119,10 @@ describe("Windows 7 fixed WebView2 runtime bundle", () => {
it("passes the configured fixed-runtime folder to WebView2 discovery and creation", () => { it("passes the configured fixed-runtime folder to WebView2 discovery and creation", () => {
expect(workspaceCargoToml).toContain('wry = { path = "vendor/wry" }'); expect(workspaceCargoToml).toContain('wry = { path = "vendor/wry" }');
expect(wryWebView2Source).toContain('std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER")'); expect(wryWebView2Source).toContain('std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER")');
expect(wryWebView2Source).toContain(`CreateCoreWebView2EnvironmentWithOptions( // The webview2_com crate indents this with 8 spaces; assert on the two
browser_executable_folder_ptr,`); // 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)"); expect(wryWebView2Source).toContain("GetAvailableCoreWebView2BrowserVersionString(browser_executable_folder_ptr, &mut versioninfo)");
}); });
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it } from "vitest";
import {
acquireDesktopAiRunSlot,
activeDesktopAiRuns,
blockingDesktopAiRunsForQuit,
bumpDesktopAiRunSeq,
cancelQueuedDesktopAiRun,
desktopAiRun,
finishDesktopAiRun,
hasActiveDesktopAiRun,
isTerminalDesktopAiRunStatus,
registerDesktopAiRun,
releaseDesktopAiRunSlot,
resetDesktopAiRunRegistryForTests,
} from "@/lib/ai/desktopAiRunRegistry";
import { resolveAiRuntimeStrategy, supportsBackgroundAiRuns } from "@/lib/ai/aiRuntimeStrategy";
describe("AI runtime platform strategy", () => {
afterEach(() => resetDesktopAiRunRegistryForTests());
it("enables detached runs only for the Tauri desktop runtime", () => {
expect(resolveAiRuntimeStrategy(true)).toBe("desktop-background");
expect(supportsBackgroundAiRuns("desktop-background")).toBe(true);
expect(resolveAiRuntimeStrategy(false)).toBe("web-cancel-on-navigation");
expect(supportsBackgroundAiRuns("web-cancel-on-navigation")).toBe(false);
});
it("keeps a desktop run addressable by conversation until terminal", () => {
const run = registerDesktopAiRun({
runId: "run-1",
conversationId: "conversation-1",
sessionIds: ["session-1"],
currentSessionId: "session-1",
status: "running",
messages: [{ content: "partial" }],
assistantMessageIndex: 0,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([run]);
finishDesktopAiRun(run, "completed");
expect(desktopAiRun("conversation-1")?.status).toBe("completed");
expect(activeDesktopAiRuns()).toEqual([]);
});
it("admits at most three runs and lets a queued run be cancelled", async () => {
const makeRun = (index: number) =>
registerDesktopAiRun({
runId: `run-${index}`,
conversationId: `conversation-${index}`,
sessionIds: [],
currentSessionId: "",
status: "preparing" as const,
messages: [],
assistantMessageIndex: -1,
connectionId: `connection-${index}`,
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const runs = [1, 2, 3, 4].map(makeRun);
await Promise.all(runs.slice(0, 3).map(acquireDesktopAiRunSlot));
const fourthAdmission = acquireDesktopAiRunSlot(runs[3]);
expect(runs[3].status).toBe("queued");
expect(cancelQueuedDesktopAiRun(runs[3])).toBe(true);
await expect(fourthAdmission).resolves.toBe(false);
runs.slice(0, 3).forEach((run) => releaseDesktopAiRunSlot(run.runId));
});
it("keeps a recovered pending-input run addressable without consuming a slot or blocking quit", async () => {
const run = registerDesktopAiRun({
runId: "run-recovered",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "pending_recoverable",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
pendingInput: "select * from orders",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Addressable (so the row can render "pending input"), but neither an
// active run nor a quit-blocking one.
expect(desktopAiRun("conversation-1")).toBe(run);
expect(activeDesktopAiRuns()).toEqual([]);
expect(blockingDesktopAiRunsForQuit()).toEqual([]);
// It also does not consume a global concurrency slot: a fresh preparing run
// is admitted immediately even though the recovered run is registered.
const fresh = registerDesktopAiRun({
runId: "run-fresh",
conversationId: "conversation-2",
sessionIds: [],
currentSessionId: "",
status: "preparing",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-2",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
const admission = acquireDesktopAiRunSlot(fresh);
expect(fresh.status).toBe("preparing");
await expect(admission).resolves.toBe(true);
releaseDesktopAiRunSlot(fresh.runId);
});
it("assigns strictly increasing event seqs that persist on the run", () => {
const run = registerDesktopAiRun({
runId: "run-seq",
conversationId: "conversation-1",
sessionIds: ["session-1", "session-2"],
currentSessionId: "session-2",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
// Parent PRD §8: seq starts at 1 and strictly increases across sessions.
expect(bumpDesktopAiRunSeq(run)).toBe(1);
expect(bumpDesktopAiRunSeq(run)).toBe(2);
expect(run.maxSeq).toBe(2);
// A recovered run with a persisted maxSeq continues from there.
run.maxSeq = 40;
expect(bumpDesktopAiRunSeq(run)).toBe(41);
expect(desktopAiRun("conversation-1")?.maxSeq).toBe(41);
});
it("treats every non-terminal status as active for queue-send, including pending_recoverable", () => {
const statuses = ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const;
for (const status of statuses) {
registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(true);
}
for (const status of ["completed", "failed", "cancelled", "interrupted"] as const) {
const run = registerDesktopAiRun({
runId: `run-${status}`,
conversationId: `conversation-${status}`,
sessionIds: [],
currentSessionId: "",
status,
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
});
expect(hasActiveDesktopAiRun(`conversation-${status}`)).toBe(false);
finishDesktopAiRun(run, "completed");
}
});
it("classifies terminal statuses for the stop-wait logic (isTerminalDesktopAiRunStatus)", () => {
for (const status of ["completed", "failed", "cancelled"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(true);
}
// `interrupted` is a persisted-row-only pseudo status, never a runtime one.
for (const status of ["preparing", "queued", "running", "awaiting_write_confirmation", "pending_recoverable"] as const) {
expect(isTerminalDesktopAiRunStatus(status)).toBe(false);
}
});
});
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDesktopAiRunSnapshotScheduler } from "@/lib/ai/desktopAiRunSnapshotScheduler";
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
function makeRun(overrides: Partial<DesktopAiRunRuntime> = {}): DesktopAiRunRuntime {
return {
runId: "run-1",
conversationId: "conversation-1",
sessionIds: [],
currentSessionId: "",
status: "running",
messages: [],
assistantMessageIndex: -1,
connectionId: "connection-1",
connectionName: "local",
database: "db",
createdAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
cancelRequested: false,
...overrides,
};
}
// Finding 1 regression coverage: detached streaming deltas used to live only
// in memory (a crash/quit mid-response lost everything after the last
// pre-stream snapshot). The scheduler persists snapshots incrementally from
// the delta flush path - throttled to keep writes cheap, serialized per run so
// a slow write can never let an older snapshot overwrite a newer one.
describe("desktop AI run snapshot scheduler", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("throttles streaming flushes to one durable save per interval, capturing state at fire time", async () => {
const persistedContentLengths: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persistedContentLengths.push(run.messages.length);
},
intervalMs: 2000,
});
run.messages.push({ content: "a" });
scheduler.schedule(run);
// Further flushes inside the window must not stack extra timers.
run.messages.push({ content: "b" });
scheduler.schedule(run);
run.messages.push({ content: "c" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(1999);
expect(persistedContentLengths).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
// The save reads the run's state when the timer fires (3 messages), not at
// schedule time - so a crash after the first flush still persists the tail.
expect(persistedContentLengths).toEqual([3]);
});
it("re-arms after each save so a long stream keeps snapshotting", async () => {
const persisted: number[] = [];
const run = makeRun();
const scheduler = createDesktopAiRunSnapshotScheduler({
persist: async () => {
persisted.push(run.messages.length);
},
intervalMs: 2000,
});
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0]);
run.messages.push({ content: "late" });
scheduler.schedule(run);
await vi.advanceTimersByTimeAsync(2000);
expect(persisted).toEqual([0, 1]);
});
it("serializes saves so an older in-flight snapshot can never overwrite a newer one", async () => {
const callOrder: number[] = [];
let resolveFirst!: () => void;
const persist = vi
.fn()
.mockImplementationOnce(() => {
callOrder.push(1);
return new Promise<void>((resolve) => {
resolveFirst = resolve;
});
})
.mockImplementationOnce(() => {
callOrder.push(2);
return Promise.resolve();
});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
const first = scheduler.save(run);
run.status = "completed";
const second = scheduler.save(run);
// The newer save must not start (let alone finish) before the older one
// resolves - saves are INSERT OR REPLACEs keyed by the same run row.
await vi.advanceTimersByTimeAsync(10_000);
expect(persist).toHaveBeenCalledTimes(1);
resolveFirst();
await Promise.all([first, second]);
expect(callOrder).toEqual([1, 2]);
});
it("skips a queued save if the run is deleted while a prior write is still in flight", async () => {
// Reviewed finding (delete-resurrection race): a save() that was enqueued
// behind a slow in-flight write was NOT re-checked for discardOnFinish at
// execution time. The delete path commits its DELETE first, so the queued
// save firing afterwards recreated the just-deleted conversation via
// INSERT OR REPLACE. The discard marker must be checked at the actual
// execution point, not just at enqueue time.
const persist = vi.fn(async () => {});
let resolveFirst!: () => void;
persist.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
);
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
// First save: flush the microtask so the write actually starts (and blocks
// on the pending mock) BEFORE the second is enqueued behind it.
const first = scheduler.save(run);
await Promise.resolve();
const second = scheduler.save(run);
// The user deletes the conversation while the first write is still running.
run.discardOnFinish = true;
resolveFirst();
await Promise.all([first, second]);
// Only the already-executing first write persisted; the queued one skipped.
expect(persist).toHaveBeenCalledTimes(1);
});
it("never persists a run the user deleted (discardOnFinish), even through the ordered path", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
scheduler.schedule(makeRun({ discardOnFinish: true }));
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// A run deleted after scheduling must be skipped when the timer fires.
const run = makeRun();
scheduler.schedule(run);
run.discardOnFinish = true;
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
// save() on a discarded run is also a no-op (delete-resurrection guard).
await scheduler.save(makeRun({ discardOnFinish: true }));
expect(persist).not.toHaveBeenCalled();
});
it("cancel() drops a pending throttled save so a late fire cannot write stale state", async () => {
const persist = vi.fn(async () => {});
const scheduler = createDesktopAiRunSnapshotScheduler({ persist, intervalMs: 2000 });
const run = makeRun();
scheduler.schedule(run);
scheduler.cancel(run.runId);
await vi.advanceTimersByTimeAsync(5000);
expect(persist).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,15 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export type AiRuntimeStrategy = "desktop-background" | "web-cancel-on-navigation";
/**
* Keep the platform boundary in one place. Desktop owns a process-wide Tauri
* event stream, while Web's POST/SSE response is still tied to the page request.
*/
export function resolveAiRuntimeStrategy(tauriRuntime = isTauriRuntime()): AiRuntimeStrategy {
return tauriRuntime ? "desktop-background" : "web-cancel-on-navigation";
}
export function supportsBackgroundAiRuns(strategy = resolveAiRuntimeStrategy()): boolean {
return strategy === "desktop-background";
}
@@ -0,0 +1,159 @@
import { shallowReactive } from "vue";
export type DesktopAiRunStatus = "preparing" | "queued" | "running" | "awaiting_write_confirmation" | "completed" | "failed" | "cancelled" | "pending_recoverable";
export type DesktopAiRunFifoCategory = "normal_send" | "write_confirmation_resume";
const DESKTOP_AI_CONCURRENCY_LIMIT = 3;
export interface DesktopAiRunRuntime<TMessage = unknown> {
runId: string;
conversationId: string;
sessionIds: string[];
currentSessionId: string;
status: DesktopAiRunStatus;
messages: TMessage[];
assistantMessageIndex: number;
connectionId: string;
connectionName: string;
database: string;
schema?: string;
pendingConfirmation?: unknown;
/** Queue category of a global-FIFO item, persisted so restart recovery can
* tell a normal send (→ editable pending input) from an accepted
* write-confirmation resume (→ back to awaiting confirmation). */
fifoCategory?: DesktopAiRunFifoCategory;
/** User input text carried by a normal-send FIFO item, recovered after
* restart as an editable, unsent pending draft. */
pendingInput?: string;
/** Highest event `seq` assigned to this run, across all its sessions (parent
* PRD §8). Strictly increasing from 1; persisted with the run. */
maxSeq?: number;
/** True when the user rejected the write-confirmation card for this run. A
* run cancelled on this path must NOT auto-send the conversation's queued
* input (parent PRD §5). In-memory only — never serialized. */
pendingConfirmationRejected?: boolean;
createdAt: string;
updatedAt: string;
cancelRequested: boolean;
discardOnFinish?: boolean;
flushPending?: () => void;
/** Resolved by the owning send() pipeline once it has fully settled (its
* finally block, or a pre-stream early return). A stop request waits on this
* signal rather than finalizing the run itself, so a cancellation-pending
* run stays visible - and keeps owning its concurrency slot - until the
* real terminal event (or a bounded force-abandon). Runtime-only - never
* serialized. */
settled?: Promise<void>;
}
// This registry deliberately lives outside AiAssistant.vue. Closing the panel
// unmounts its view in some layouts, but a Desktop run remains addressable until
// it reaches a terminal state. Web never registers runs here.
const runsByConversation = shallowReactive(new Map<string, DesktopAiRunRuntime>());
const admittedRunIds = new Set<string>();
const admissionQueue: Array<{ run: DesktopAiRunRuntime; resolve: (admitted: boolean) => void }> = [];
export function registerDesktopAiRun<TMessage>(run: DesktopAiRunRuntime<TMessage>): DesktopAiRunRuntime<TMessage> {
runsByConversation.set(run.conversationId, run as DesktopAiRunRuntime);
return run;
}
export function desktopAiRun<TMessage = unknown>(conversationId: string): DesktopAiRunRuntime<TMessage> | undefined {
return runsByConversation.get(conversationId) as DesktopAiRunRuntime<TMessage> | undefined;
}
export function updateDesktopAiRun(run: DesktopAiRunRuntime, patch: Partial<DesktopAiRunRuntime>): void {
Object.assign(run, patch, { updatedAt: new Date().toISOString() });
// Values are intentionally shallow (message arrays can be large). Re-setting
// the entry invalidates count/badge computations when status changes.
runsByConversation.set(run.conversationId, run);
}
export function finishDesktopAiRun(run: DesktopAiRunRuntime, status: Extract<DesktopAiRunStatus, "completed" | "failed" | "cancelled">): void {
updateDesktopAiRun(run, { status, currentSessionId: "", flushPending: undefined, pendingConfirmation: undefined });
}
export function acquireDesktopAiRunSlot(run: DesktopAiRunRuntime): Promise<boolean> {
if (admittedRunIds.has(run.runId)) return Promise.resolve(true);
if (admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
admittedRunIds.add(run.runId);
return Promise.resolve(true);
}
updateDesktopAiRun(run, { status: "queued" });
return new Promise((resolve) => admissionQueue.push({ run, resolve }));
}
export function releaseDesktopAiRunSlot(runId: string): void {
if (!admittedRunIds.delete(runId)) return;
while (admissionQueue.length > 0 && admittedRunIds.size < DESKTOP_AI_CONCURRENCY_LIMIT) {
const next = admissionQueue.shift()!;
if (next.run.cancelRequested || next.run.status === "cancelled") {
next.resolve(false);
continue;
}
admittedRunIds.add(next.run.runId);
updateDesktopAiRun(next.run, { status: "preparing" });
next.resolve(true);
}
}
export function cancelQueuedDesktopAiRun(run: DesktopAiRunRuntime): boolean {
const index = admissionQueue.findIndex((entry) => entry.run === run);
if (index < 0) return false;
const [entry] = admissionQueue.splice(index, 1);
run.cancelRequested = true;
finishDesktopAiRun(run, "cancelled");
entry.resolve(false);
return true;
}
export function activeDesktopAiRuns(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation");
}
/** Terminal statuses: the run has fully settled and no longer blocks the
* conversation, the quit prompt, or a concurrency slot. `interrupted` is a
* persisted-row-only pseudo status (never a runtime status) and is excluded
* here for that reason. */
export function isTerminalDesktopAiRunStatus(status: DesktopAiRunStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
/** Statuses that block queue-send/auto-send: the conversation already owns a
* run the queued input must wait behind (parent PRD §5). */
export function hasActiveDesktopAiRun(conversationId: string): boolean {
const run = runsByConversation.get(conversationId);
if (!run) return false;
return run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation" || run.status === "pending_recoverable";
}
export function blockingDesktopAiRunsForQuit(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running");
}
/**
* Advances a run's event `seq` (parent PRD §8). Every agent event for the run —
* across all its sessions — takes the next integer, strictly increasing from 1.
* Returns the new seq so the caller can record it as the last-processed anchor.
*/
export function bumpDesktopAiRunSeq(run: DesktopAiRunRuntime): number {
const next = (run.maxSeq ?? 0) + 1;
run.maxSeq = next;
runsByConversation.set(run.conversationId, run);
return next;
}
export function removeDesktopAiRun(conversationId: string): void {
runsByConversation.delete(conversationId);
}
export function retireDesktopAiRun(run: DesktopAiRunRuntime): void {
if (runsByConversation.get(run.conversationId) === run) runsByConversation.delete(run.conversationId);
}
export function resetDesktopAiRunRegistryForTests(): void {
runsByConversation.clear();
admittedRunIds.clear();
while (admissionQueue.length) admissionQueue.shift()!.resolve(false);
}
@@ -0,0 +1,83 @@
import type { DesktopAiRunRuntime } from "@/lib/ai/desktopAiRunRegistry";
export interface DesktopAiRunSnapshotSchedulerOptions<TMessage> {
/** Durable save for one run. Runs while the previous save for the same run
* is still in flight are deferred, so call order equals write order.
* Rejections are swallowed by the scheduler - a failed save must never
* break the chain for the saves behind it. */
persist: (run: DesktopAiRunRuntime<TMessage>) => Promise<void>;
/** Minimum spacing between throttled saves for one run. Bounds how much
* streamed output a crash/quit can lose relative to the last durable
* snapshot. */
intervalMs: number;
}
export interface DesktopAiRunSnapshotScheduler<TMessage = unknown> {
/** Schedules a throttled save for a streaming run: at most one save per
* intervalMs, capturing whatever the run's state is when the timer fires
* (not when the flush that scheduled it happened). */
schedule(run: DesktopAiRunRuntime<TMessage>): void;
/** Saves immediately, strictly ordered after any save already queued or in
* flight for the run, so an older snapshot can never overwrite a newer
* one (saves are INSERT OR REPLACEs keyed by the same row). */
save(run: DesktopAiRunRuntime<TMessage>): Promise<void>;
/** Drops a pending throttled save - call when the run settles or is deleted,
* so a late fire cannot write stale state over the authoritative final
* save (or resurrect a deleted conversation). */
cancel(runId: string): void;
}
// Streaming deltas used to live only in memory: a crash or quit mid-response
// lost everything after the last pre-stream snapshot. The scheduler persists
// snapshots incrementally from the delta flush path while keeping the writes
// cheap (throttled) and ordered (serialized per run).
export function createDesktopAiRunSnapshotScheduler<TMessage>(options: DesktopAiRunSnapshotSchedulerOptions<TMessage>): DesktopAiRunSnapshotScheduler<TMessage> {
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
const chains = new Map<string, Promise<void>>();
const enqueue = (run: DesktopAiRunRuntime<TMessage>): Promise<void> => {
const chain = (chains.get(run.runId) ?? Promise.resolve())
.then(() => {
// Re-check discard at the actual execution point, not just at enqueue
// time: the run may have been deleted while an earlier write in this
// chain was still in flight. Without this re-check the queued save
// would persist anyway and resurrect the deleted conversation via
// INSERT OR REPLACE.
if (run.discardOnFinish) return;
return options.persist(run);
})
.catch(() => {})
.finally(() => {
if (chains.get(run.runId) === chain) chains.delete(run.runId);
});
chains.set(run.runId, chain);
return chain;
};
return {
schedule(run) {
// A run the user deleted must never be written back - INSERT OR REPLACE
// would resurrect the just-deleted conversation.
if (run.discardOnFinish) return;
if (pendingTimers.has(run.runId)) return;
const timer = setTimeout(() => {
pendingTimers.delete(run.runId);
if (run.discardOnFinish) return;
void enqueue(run);
}, options.intervalMs);
pendingTimers.set(run.runId, timer);
},
save(run) {
// Same delete-resurrection guard as schedule(): no caller may persist a
// discarded run, not even through the ordered path.
if (run.discardOnFinish) return Promise.resolve();
return enqueue(run);
},
cancel(runId) {
const timer = pendingTimers.get(runId);
if (timer === undefined) return;
clearTimeout(timer);
pendingTimers.delete(runId);
},
};
}
+5
View File
@@ -345,6 +345,9 @@ export const snippetSyncDownload = forward("snippetSyncDownload");
export const saveAiConversation = forward("saveAiConversation"); export const saveAiConversation = forward("saveAiConversation");
export const loadAiConversations = forward("loadAiConversations"); export const loadAiConversations = forward("loadAiConversations");
export const deleteAiConversation = forward("deleteAiConversation"); export const deleteAiConversation = forward("deleteAiConversation");
export const saveAiRun = forward("saveAiRun");
export const saveAiRunState = forward("saveAiRunState");
export const loadAiRuns = forward("loadAiRuns");
// Prompt Templates // Prompt Templates
export const loadPromptTemplates = forward("loadPromptTemplates"); export const loadPromptTemplates = forward("loadPromptTemplates");
@@ -821,6 +824,8 @@ export type {
AiModelInfo, AiModelInfo,
AiChatMessage, AiChatMessage,
AiConversation, AiConversation,
AiRun,
AiRunStatus,
PromptTemplate, PromptTemplate,
AgentDriverInfo, AgentDriverInfo,
AgentOfflineArtifactKind, AgentOfflineArtifactKind,
+36 -12
View File
@@ -60,6 +60,7 @@ import type {
AiCompletionRequest, AiCompletionRequest,
AiStreamChunk, AiStreamChunk,
AiConversation, AiConversation,
AiRun,
AiModelInfo, AiModelInfo,
DriverStoreUsage, DriverStoreUsage,
DriverRuntimeSummary, DriverRuntimeSummary,
@@ -1590,13 +1591,16 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
const data = line.slice(5).trim(); const data = line.slice(5).trim();
if (data && data !== "[DONE]") { if (data && data !== "[DONE]") {
let chunk: AiStreamChunk;
try { try {
const chunk: AiStreamChunk = JSON.parse(data); chunk = JSON.parse(data);
onChunk(chunk);
if (chunk.done) return;
} catch { } catch {
// skip malformed JSON // 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:")) { if (line.startsWith("data:")) {
const data = line.slice(5).trim(); const data = line.slice(5).trim();
if (data && data !== "[DONE]") { if (data && data !== "[DONE]") {
let parsed: unknown;
try { try {
const parsed = JSON.parse(data); 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;
}
} catch { } catch {
// skip malformed JSON // 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}`); 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 // Prompt Templates
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+40
View File
@@ -447,6 +447,8 @@ export interface AiStreamChunk {
delta: string; delta: string;
reasoning_delta?: string; reasoning_delta?: string;
done: boolean; 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> { export async function aiStream(sessionId: string, request: AiCompletionRequest, onChunk: (chunk: AiStreamChunk) => void): Promise<void> {
@@ -904,6 +906,32 @@ export interface AiConversation {
connectionName: string; connectionName: string;
database: string; database: string;
messages: AiChatMessage[]; 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; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -920,6 +948,18 @@ export async function deleteAiConversation(id: string): Promise<void> {
return invoke("delete_ai_conversation", { id }); 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 --- // --- Prompt Templates ---
export interface PromptTemplate { export interface PromptTemplate {
+55 -1
View File
@@ -1,4 +1,5 @@
use std::sync::Arc; use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
use serde_json::json; use serde_json::json;
@@ -36,6 +37,32 @@ const MAX_QUERY_CELL_CHAR_LIMIT: usize = 4_000;
/// Bounds explicit sliding-window scans without changing the underlying database request. /// Bounds explicit sliding-window scans without changing the underlying database request.
const MAX_QUERY_CELL_CHAR_OFFSET: usize = 1_000_000; const MAX_QUERY_CELL_CHAR_OFFSET: usize = 1_000_000;
fn connection_tool_lock(connection_id: &str) -> Arc<tokio::sync::Mutex<()>> {
static LOCKS: OnceLock<StdMutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>> = OnceLock::new();
let locks = LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
let mut registry = locks.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
registry.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = registry.get(connection_id).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(tokio::sync::Mutex::new(()));
registry.insert(connection_id.to_string(), Arc::downgrade(&lock));
lock
}
fn tool_uses_database(tool_name: &str) -> bool {
matches!(
tool_name,
"list_tables"
| "get_columns"
| "execute_query"
| "get_sample_data"
| "list_collections"
| "browse_collection"
| "explain_query"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QueryCellWindow { pub struct QueryCellWindow {
offset: usize, offset: usize,
@@ -506,6 +533,15 @@ pub async fn execute_tool(
db_type: &DatabaseType, db_type: &DatabaseType,
sql_permissions: AgentSqlPermissions, sql_permissions: AgentSqlPermissions,
) -> ToolResult { ) -> ToolResult {
// Agent loops for different conversations may share a physical connection.
// Lock only the database tool future; model generation and non-DB tools stay
// concurrent. Dropping a cancelled future releases either the waiter or the
// acquired guard automatically.
let _connection_guard = if tool_uses_database(&tool_call.name) {
Some(connection_tool_lock(connection_id).lock_owned().await)
} else {
None
};
let result = match tool_call.name.as_str() { let result = match tool_call.name.as_str() {
"list_tables" => execute_list_tables(tool_call, state, connection_id, database, default_schema, db_type).await, "list_tables" => execute_list_tables(tool_call, state, connection_id, database, default_schema, db_type).await,
"get_columns" => execute_get_columns(tool_call, state, connection_id, database, default_schema, db_type).await, "get_columns" => execute_get_columns(tool_call, state, connection_id, database, default_schema, db_type).await,
@@ -1243,6 +1279,24 @@ async fn resolve_chroma_collection_uuid(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn database_tool_locks_are_connection_scoped_and_cancel_safe() {
let first = connection_tool_lock("agent-tool-lock-a").lock_owned().await;
let same_connection = connection_tool_lock("agent-tool-lock-a");
let other_connection = connection_tool_lock("agent-tool-lock-b");
assert!(same_connection.try_lock().is_err(), "same connection must serialize tool calls");
assert!(other_connection.try_lock().is_ok(), "different connections must remain concurrent");
let waiter = tokio::spawn(async move { same_connection.lock_owned().await });
tokio::task::yield_now().await;
waiter.abort();
let _ = waiter.await;
drop(first);
assert!(connection_tool_lock("agent-tool-lock-a").try_lock().is_ok());
}
#[cfg(unix)] #[cfg(unix)]
use crate::connection::PoolKind; use crate::connection::PoolKind;
#[cfg(unix)] #[cfg(unix)]
+125
View File
@@ -558,6 +558,131 @@ pub struct AiConversation {
pub connection_name: String, pub connection_name: String,
pub database: String, pub database: String,
pub messages: Vec<AiChatMessage>, pub messages: Vec<AiChatMessage>,
/// One editable "send later" input saved while an active run occupies the
/// conversation (parent PRD §5). Persisted with the conversation so it
/// survives a restart and is restored when the conversation reopens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queued_input: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiRunStatus {
Preparing,
Queued,
Running,
AwaitingWriteConfirmation,
Completed,
Failed,
Cancelled,
Interrupted,
/// A normal-send FIFO item recovered after a process restart. The user's
/// input was never actually submitted, so the run is surfaced as an
/// editable, unsent pending draft — never as a failure/interruption.
/// (Parent PRD §7: "恢复为其会话中可编辑、未发送的待发输入".)
PendingRecoverable,
}
impl AiRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Preparing => "preparing",
Self::Queued => "queued",
Self::Running => "running",
Self::AwaitingWriteConfirmation => "awaiting_write_confirmation",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::Interrupted => "interrupted",
Self::PendingRecoverable => "pending_recoverable",
}
}
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"preparing" => Ok(Self::Preparing),
"queued" => Ok(Self::Queued),
"running" => Ok(Self::Running),
"awaiting_write_confirmation" => Ok(Self::AwaitingWriteConfirmation),
"completed" => Ok(Self::Completed),
"failed" => Ok(Self::Failed),
"cancelled" => Ok(Self::Cancelled),
"interrupted" => Ok(Self::Interrupted),
"pending_recoverable" => Ok(Self::PendingRecoverable),
other => Err(format!("Unknown AI run status: {other}")),
}
}
pub fn protects_conversation(self) -> bool {
matches!(
self,
Self::Preparing | Self::Queued | Self::Running | Self::AwaitingWriteConfirmation | Self::PendingRecoverable
)
}
}
/// Why a run occupies the global FIFO queue. Normal sends and accepted
/// write-confirmation resumes recover differently after a restart (parent
/// PRD §3 line 58), so the category must be persisted.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiRunFifoCategory {
/// A user-sent message waiting for a global concurrency slot.
NormalSend,
/// An accepted write confirmation whose resume segment is queued.
WriteConfirmationResume,
}
impl AiRunFifoCategory {
pub fn as_str(self) -> &'static str {
match self {
Self::NormalSend => "normal_send",
Self::WriteConfirmationResume => "write_confirmation_resume",
}
}
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"normal_send" => Ok(Self::NormalSend),
"write_confirmation_resume" => Ok(Self::WriteConfirmationResume),
other => Err(format!("Unknown AI run FIFO category: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiRun {
pub run_id: String,
pub conversation_id: String,
#[serde(default)]
pub session_ids: Vec<String>,
pub status: AiRunStatus,
#[serde(default)]
pub connection_id: String,
#[serde(default)]
pub database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_confirmation: Option<serde_json::Value>,
/// Which queue category this run occupied. Persisted so restart recovery
/// can distinguish a normal send (→ editable pending input) from an
/// accepted write-confirmation resume (→ back to awaiting confirmation).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fifo_category: Option<AiRunFifoCategory>,
/// The user's input text for a normal-send FIFO item, recovered after
/// restart as an editable, unsent pending draft.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_input: Option<String>,
/// Highest event `seq` ever assigned to this run, across all its sessions
/// (parent PRD §8). Strictly increasing from 1; the run manager persists it
/// to drive event dedup, the unread baseline, and the "updates while you
/// were away" separator anchor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_seq: Option<u64>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
+543 -19
View File
@@ -4,13 +4,16 @@ use std::time::{SystemTime, UNIX_EPOCH};
use log::warn; use log::warn;
use rusqlite::{ use rusqlite::{
params, params_from_iter, types::Value, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql, params, params_from_iter, types::Value, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql, Transaction,
TransactionBehavior, TransactionBehavior,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::ai::{AiChatMessage, AiChatSelectionState, AiConfig, AiConfigItem, AiConversation, AiProvider}; use crate::ai::{
AiChatMessage, AiChatSelectionState, AiConfig, AiConfigItem, AiConversation, AiProvider, AiRun, AiRunFifoCategory,
AiRunStatus,
};
use crate::connection_secrets::{ use crate::connection_secrets::{
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY,
@@ -43,6 +46,7 @@ const USER_DATA_TABLES: &[&str] = &[
"connection_secrets", "connection_secrets",
"history", "history",
"ai_conversations", "ai_conversations",
"ai_runs",
"mq_token_records", "mq_token_records",
"saved_sql_folders", "saved_sql_folders",
"saved_sql_files", "saved_sql_files",
@@ -364,9 +368,27 @@ const SCHEMA_STATEMENTS: &[&str] = &[
connection_name TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL DEFAULT '', database TEXT NOT NULL DEFAULT '',
messages_json TEXT NOT NULL DEFAULT '[]', messages_json TEXT NOT NULL DEFAULT '[]',
queued_input TEXT,
created_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '' updated_at TEXT NOT NULL DEFAULT ''
)", )",
"CREATE TABLE IF NOT EXISTS ai_runs (
run_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
session_ids_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL,
connection_id TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL DEFAULT '',
schema_name TEXT,
pending_confirmation_json TEXT,
fifo_category TEXT,
pending_input TEXT,
max_seq INTEGER,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
FOREIGN KEY (conversation_id) REFERENCES ai_conversations(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS idx_ai_runs_conversation_status ON ai_runs(conversation_id, status)",
"CREATE TABLE IF NOT EXISTS sidebar_layout ( "CREATE TABLE IF NOT EXISTS sidebar_layout (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
layout_json TEXT NOT NULL layout_json TEXT NOT NULL
@@ -532,6 +554,8 @@ impl Storage {
ensure_schema_cache_columns_sync(conn)?; ensure_schema_cache_columns_sync(conn)?;
ensure_ai_configs_columns_sync(conn)?; ensure_ai_configs_columns_sync(conn)?;
ensure_state_store_columns_sync(conn)?; ensure_state_store_columns_sync(conn)?;
ensure_ai_conversations_columns_sync(conn)?;
ensure_ai_runs_columns_sync(conn)?;
Ok(()) Ok(())
}) })
} }
@@ -730,6 +754,24 @@ fn ensure_ai_configs_columns_sync(conn: &Connection) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Adds the queued-input column to `ai_conversations` for databases created
/// by earlier iterations of the uncommitted WIP, where the table predates it.
fn ensure_ai_conversations_columns_sync(conn: &Connection) -> Result<(), String> {
const COLUMNS: &[(&str, &str)] = &[("queued_input", "TEXT")];
ensure_table_columns(conn, "ai_conversations", COLUMNS)
}
/// Adds the background-run recovery columns (`fifo_category`, `pending_input`,
/// `max_seq`) to databases created by earlier iterations of the uncommitted
/// WIP, where the `ai_runs` table predates these fields.
fn ensure_ai_runs_columns_sync(conn: &Connection) -> Result<(), String> {
const COLUMNS: &[(&str, &str)] = &[("fifo_category", "TEXT"), ("pending_input", "TEXT"), ("max_seq", "INTEGER")];
ensure_table_columns(conn, "ai_runs", COLUMNS)
}
fn ensure_table_columns(conn: &Connection, table_name: &str, columns: &[(&str, &str)]) -> Result<(), String> { fn ensure_table_columns(conn: &Connection, table_name: &str, columns: &[(&str, &str)]) -> Result<(), String> {
let mut stmt = let mut stmt =
conn.prepare(&format!("SELECT name FROM pragma_table_info('{table_name}')")).map_err(|e| e.to_string())?; conn.prepare(&format!("SELECT name FROM pragma_table_info('{table_name}')")).map_err(|e| e.to_string())?;
@@ -2136,34 +2178,109 @@ impl Storage {
// AI Conversations // AI Conversations
// Terminal runs beyond this many per conversation are pruned on every AI save.
// Only the newest terminal run per conversation is ever read at recovery (it
// drives the history row's status badge after a restart); the older ones are
// pure, unbounded storage + startup-load growth.
const KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION: i64 = 2;
/// Caps each conversation's terminal run history. `save_ai_run` /
/// `save_ai_run_state` persist every run unconditionally and `load_ai_runs`
/// loads the whole table at startup, so without this cap repeated completed/
/// failed/cancelled runs grow SQLite storage and recovery work forever. The
/// frontend recovery loop dedups to the newest run per conversation, so keeping
/// the newest few terminal runs preserves the row status badge exactly while
/// bounding the table. Non-terminal statuses (preparing/queued/running/
/// awaiting_write_confirmation/pending_recoverable) are never touched - they
/// are the recovery payload.
fn prune_terminal_ai_runs(tx: &Transaction<'_>) -> Result<(), String> {
tx.execute(
"DELETE FROM ai_runs
WHERE status IN ('completed', 'failed', 'cancelled', 'interrupted')
AND run_id NOT IN (
SELECT run_id FROM (
SELECT run_id,
ROW_NUMBER() OVER (
PARTITION BY conversation_id
ORDER BY updated_at DESC, run_id DESC
) AS rn
FROM ai_runs
WHERE status IN ('completed', 'failed', 'cancelled', 'interrupted')
) WHERE rn <= ?1
)",
params![KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION],
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn prune_ai_conversations(tx: &Transaction<'_>) -> Result<(), String> {
// The 50-row limit is a soft cap: conversations with active or actionable
// runs remain reachable even when they exceed the cap. Only terminal,
// unprotected conversations compete for the remaining budget.
tx.execute(
"WITH protected AS (
SELECT DISTINCT conversation_id FROM ai_runs
WHERE status IN ('preparing', 'queued', 'running', 'awaiting_write_confirmation', 'pending_recoverable')
), budget AS (
SELECT MAX(0, 50 - COUNT(*)) AS value FROM protected
), keepers AS (
SELECT conversation_id AS id FROM protected
UNION
SELECT id FROM (
SELECT id FROM ai_conversations
WHERE id NOT IN (SELECT conversation_id FROM protected)
ORDER BY updated_at DESC
LIMIT (SELECT value FROM budget)
)
)
DELETE FROM ai_conversations WHERE id NOT IN (SELECT id FROM keepers)",
[],
)
.map_err(|e| e.to_string())?;
// Do not depend on a connection-wide foreign_keys pragma for cleanup.
tx.execute("DELETE FROM ai_runs WHERE conversation_id NOT IN (SELECT id FROM ai_conversations)", [])
.map_err(|e| e.to_string())?;
// Cap terminal run history for the conversations that survive the cap
// above (they are deliberately retained), so normal use cannot grow the
// ai_runs table without bound.
prune_terminal_ai_runs(tx)?;
Ok(())
}
impl Storage { impl Storage {
pub async fn save_ai_conversation(&self, conv: &AiConversation) -> Result<(), String> { pub async fn save_ai_conversation(&self, conv: &AiConversation) -> Result<(), String> {
let conv = conv.clone(); let conv = conv.clone();
let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?; let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?;
self.with_conn(move |conn| { self.with_conn(move |conn| {
conn.execute( let tx = conn.transaction().map_err(|e| e.to_string())?;
"INSERT OR REPLACE INTO ai_conversations \ tx.execute(
(id, title, connection_name, database, messages_json, created_at, updated_at) \ "INSERT INTO ai_conversations \
VALUES (?, ?, ?, ?, ?, ?, ?)", (id, title, connection_name, database, messages_json, queued_input, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
title = excluded.title, \
connection_name = excluded.connection_name, \
database = excluded.database, \
messages_json = excluded.messages_json, \
queued_input = excluded.queued_input, \
created_at = excluded.created_at, \
updated_at = excluded.updated_at",
params![ params![
conv.id, conv.id,
conv.title, conv.title,
conv.connection_name, conv.connection_name,
conv.database, conv.database,
messages_json, messages_json,
conv.queued_input,
conv.created_at, conv.created_at,
conv.updated_at conv.updated_at
], ],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
conn.execute( prune_ai_conversations(&tx)?;
"DELETE FROM ai_conversations WHERE id NOT IN \ tx.commit().map_err(|e| e.to_string())
(SELECT id FROM ai_conversations ORDER BY updated_at DESC LIMIT 50)",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}) })
.await .await
} }
@@ -2172,7 +2289,7 @@ impl Storage {
self.with_conn(|conn| { self.with_conn(|conn| {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, title, connection_name, database, messages_json, created_at, updated_at \ "SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at \
FROM ai_conversations ORDER BY updated_at DESC", FROM ai_conversations ORDER BY updated_at DESC",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2187,8 +2304,9 @@ impl Storage {
connection_name: row.get(2)?, connection_name: row.get(2)?,
database: row.get(3)?, database: row.get(3)?,
messages, messages,
created_at: row.get(5)?, queued_input: row.get(5)?,
updated_at: row.get(6)?, created_at: row.get(6)?,
updated_at: row.get(7)?,
}) })
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2200,7 +2318,179 @@ impl Storage {
pub async fn delete_ai_conversation(&self, id: &str) -> Result<(), String> { pub async fn delete_ai_conversation(&self, id: &str) -> Result<(), String> {
let id = id.to_string(); let id = id.to_string();
self.with_conn(move |conn| { self.with_conn(move |conn| {
conn.execute("DELETE FROM ai_conversations WHERE id = ?1", [id]).map(|_| ()).map_err(|e| e.to_string()) let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute("DELETE FROM ai_runs WHERE conversation_id = ?1", [&id]).map_err(|e| e.to_string())?;
tx.execute("DELETE FROM ai_conversations WHERE id = ?1", [&id]).map_err(|e| e.to_string())?;
prune_ai_conversations(&tx)?;
tx.commit().map_err(|e| e.to_string())
})
.await
}
pub async fn save_ai_run(&self, run: &AiRun) -> Result<(), String> {
let run = run.clone();
let session_ids_json = serde_json::to_string(&run.session_ids).map_err(|e| e.to_string())?;
let pending_confirmation_json = run.pending_confirmation.map(|value| value.to_string());
let fifo_category = run.fifo_category.map(|c| c.as_str().to_string());
self.with_conn(move |conn| {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT OR REPLACE INTO ai_runs
(run_id, conversation_id, session_ids_json, status, connection_id, database, schema_name,
pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
run.run_id,
run.conversation_id,
session_ids_json,
run.status.as_str(),
run.connection_id,
run.database,
run.schema,
pending_confirmation_json,
fifo_category,
run.pending_input,
run.max_seq,
run.created_at,
run.updated_at,
],
)
.map_err(|e| e.to_string())?;
prune_ai_conversations(&tx)?;
tx.commit().map_err(|e| e.to_string())
})
.await
}
pub async fn save_ai_run_state(&self, conv: &AiConversation, run: &AiRun) -> Result<(), String> {
let conv = conv.clone();
let run = run.clone();
let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?;
let session_ids_json = serde_json::to_string(&run.session_ids).map_err(|e| e.to_string())?;
let pending_confirmation_json = run.pending_confirmation.map(|value| value.to_string());
let fifo_category = run.fifo_category.map(|c| c.as_str().to_string());
self.with_conn(move |conn| {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO ai_conversations
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
connection_name = excluded.connection_name,
database = excluded.database,
messages_json = excluded.messages_json,
queued_input = excluded.queued_input,
created_at = excluded.created_at,
updated_at = excluded.updated_at",
params![
conv.id,
conv.title,
conv.connection_name,
conv.database,
messages_json,
conv.queued_input,
conv.created_at,
conv.updated_at
],
)
.map_err(|e| e.to_string())?;
tx.execute(
"INSERT OR REPLACE INTO ai_runs
(run_id, conversation_id, session_ids_json, status, connection_id, database, schema_name,
pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
run.run_id,
run.conversation_id,
session_ids_json,
run.status.as_str(),
run.connection_id,
run.database,
run.schema,
pending_confirmation_json,
fifo_category,
run.pending_input,
run.max_seq,
run.created_at,
run.updated_at,
],
)
.map_err(|e| e.to_string())?;
prune_ai_conversations(&tx)?;
tx.commit().map_err(|e| e.to_string())
})
.await
}
pub async fn load_ai_runs(&self) -> Result<Vec<AiRun>, String> {
self.with_conn(|conn| {
let mut stmt = conn
.prepare(
"SELECT run_id, conversation_id, session_ids_json, status, connection_id, database,
schema_name, pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at
FROM ai_runs ORDER BY updated_at DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let session_ids_json: String = row.get(2)?;
let status: String = row.get(3)?;
let pending_confirmation_json: Option<String> = row.get(7)?;
let fifo_category: Option<String> = row.get(8)?;
let pending_input: Option<String> = row.get(9)?;
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
session_ids_json,
status,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, Option<String>>(6)?,
pending_confirmation_json,
fifo_category,
pending_input,
row.get::<_, Option<u64>>(10)?,
row.get::<_, String>(11)?,
row.get::<_, String>(12)?,
))
})
.map_err(|e| e.to_string())?;
rows.map(|row| {
let (
run_id,
conversation_id,
session_ids_json,
status,
connection_id,
database,
schema,
pending_confirmation_json,
fifo_category,
pending_input,
max_seq,
created_at,
updated_at,
) = row.map_err(|e| e.to_string())?;
Ok(AiRun {
run_id,
conversation_id,
session_ids: serde_json::from_str(&session_ids_json).map_err(|e| e.to_string())?,
status: AiRunStatus::parse(&status)?,
connection_id,
database,
schema,
pending_confirmation: pending_confirmation_json
.map(|json| serde_json::from_str(&json).map_err(|e| e.to_string()))
.transpose()?,
fifo_category: fifo_category.map(|value| AiRunFifoCategory::parse(&value)).transpose()?,
pending_input,
max_seq,
created_at,
updated_at,
})
})
.collect::<Result<Vec<_>, String>>()
}) })
.await .await
} }
@@ -4385,10 +4675,11 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
mod tests { mod tests {
use super::{ use super::{
maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, McpGlobalPolicy, maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, McpGlobalPolicy,
McpGlobalPolicyState, Storage, MCP_GLOBAL_POLICY_KEY, McpGlobalPolicyState, Storage, KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION, MCP_GLOBAL_POLICY_KEY,
}; };
use crate::ai::{ use crate::ai::{
AiActiveModelSelection, AiAssistantMode, AiChatSelectionState, AiEffortSelection, AiModelEffortPreference, AiActiveModelSelection, AiAssistantMode, AiChatMessage, AiChatSelectionState, AiConversation,
AiEffortSelection, AiModelEffortPreference, AiRun, AiRunFifoCategory, AiRunStatus,
}; };
use crate::connection_secrets::NACOS_RNACOS_CONSOLE_PASSWORD_KEY; use crate::connection_secrets::NACOS_RNACOS_CONSOLE_PASSWORD_KEY;
use crate::connection_secrets::{ use crate::connection_secrets::{
@@ -4440,6 +4731,239 @@ mod tests {
} }
} }
fn ai_conversation(id: &str, updated_at: &str) -> AiConversation {
AiConversation {
id: id.to_string(),
title: id.to_string(),
connection_name: "local".to_string(),
database: "db".to_string(),
messages: vec![AiChatMessage {
role: "user".to_string(),
content: id.to_string(),
mentions: None,
reasoning: None,
kind: None,
covered_messages: None,
}],
queued_input: None,
created_at: updated_at.to_string(),
updated_at: updated_at.to_string(),
}
}
fn ai_run(id: &str, conversation_id: &str, status: AiRunStatus, updated_at: &str) -> AiRun {
AiRun {
run_id: id.to_string(),
conversation_id: conversation_id.to_string(),
session_ids: vec![],
status,
connection_id: "connection".to_string(),
database: "db".to_string(),
schema: None,
pending_confirmation: None,
fifo_category: None,
pending_input: None,
max_seq: None,
created_at: updated_at.to_string(),
updated_at: updated_at.to_string(),
}
}
#[tokio::test]
async fn ai_conversation_soft_cap_never_evicts_protected_runs() {
let path = temp_db_path("ai-conversation-soft-cap");
let storage = Storage::open(&path).await.unwrap();
let protected = ai_conversation("protected", "0000");
let protected_run = ai_run("protected-run", "protected", AiRunStatus::Running, "0000");
storage.save_ai_run_state(&protected, &protected_run).await.unwrap();
for index in 0..55 {
let timestamp = format!("{index:04}");
storage.save_ai_conversation(&ai_conversation(&format!("terminal-{index}"), &timestamp)).await.unwrap();
}
let conversations = storage.load_ai_conversations().await.unwrap();
assert_eq!(conversations.len(), 50);
assert!(conversations.iter().any(|conversation| conversation.id == "protected"));
assert!(!conversations.iter().any(|conversation| conversation.id == "terminal-0"));
assert!(conversations.iter().any(|conversation| conversation.id == "terminal-54"));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_soft_cap_allows_more_than_fifty_protected_runs() {
let path = temp_db_path("ai-conversation-protected-overflow");
let storage = Storage::open(&path).await.unwrap();
for index in 0..51 {
let id = format!("protected-{index}");
let timestamp = format!("{index:04}");
storage
.save_ai_run_state(
&ai_conversation(&id, &timestamp),
&ai_run(&format!("run-{index}"), &id, AiRunStatus::AwaitingWriteConfirmation, &timestamp),
)
.await
.unwrap();
}
storage.save_ai_conversation(&ai_conversation("terminal-extra", "9999")).await.unwrap();
let conversations = storage.load_ai_conversations().await.unwrap();
assert_eq!(conversations.len(), 51);
assert!(conversations.iter().all(|conversation| conversation.id.starts_with("protected-")));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_soft_cap_protects_pending_recoverable_runs() {
let path = temp_db_path("ai-conversation-pending-recoverable-protection");
let storage = Storage::open(&path).await.unwrap();
// A recovered pending-input run (PRD §7 line 93) must be protected like
// any other non-terminal run: its draft is not lost to pruning.
let protected = ai_conversation("recoverable", "0000");
let mut protected_run = ai_run("recoverable-run", "recoverable", AiRunStatus::PendingRecoverable, "0000");
protected_run.pending_input = Some("recover me".to_string());
storage.save_ai_run_state(&protected, &protected_run).await.unwrap();
for index in 0..55 {
let timestamp = format!("{index:04}");
storage.save_ai_conversation(&ai_conversation(&format!("terminal-{index}"), &timestamp)).await.unwrap();
}
let conversations = storage.load_ai_conversations().await.unwrap();
assert_eq!(conversations.len(), 50);
assert!(conversations.iter().any(|conversation| conversation.id == "recoverable"));
assert!(!conversations.iter().any(|conversation| conversation.id == "terminal-0"));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_run_roundtrips_fifo_category_and_pending_input() {
let path = temp_db_path("ai-run-fifo-category-roundtrip");
let storage = Storage::open(&path).await.unwrap();
let conversation = ai_conversation("fifo-conv", "0000");
let mut run = ai_run("fifo-run", "fifo-conv", AiRunStatus::Queued, "0000");
run.fifo_category = Some(AiRunFifoCategory::NormalSend);
run.pending_input = Some("select * from orders limit 5".to_string());
run.max_seq = Some(42);
storage.save_ai_run_state(&conversation, &run).await.unwrap();
let loaded = storage.load_ai_runs().await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].run_id, "fifo-run");
assert_eq!(loaded[0].status, AiRunStatus::Queued);
assert_eq!(loaded[0].fifo_category, Some(AiRunFifoCategory::NormalSend));
assert_eq!(loaded[0].pending_input.as_deref(), Some("select * from orders limit 5"));
assert_eq!(loaded[0].max_seq, Some(42));
// The write_confirmation_resume category survives too.
let mut resume = ai_run("resume-run", "fifo-conv", AiRunStatus::Queued, "0001");
resume.fifo_category = Some(AiRunFifoCategory::WriteConfirmationResume);
storage.save_ai_run(&resume).await.unwrap();
let loaded = storage.load_ai_runs().await.unwrap();
let resume = loaded.iter().find(|run| run.run_id == "resume-run").unwrap();
assert_eq!(resume.fifo_category, Some(AiRunFifoCategory::WriteConfirmationResume));
assert!(resume.pending_input.is_none());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn saving_a_conversation_keeps_its_background_runs() {
let path = temp_db_path("ai-conversation-upsert-keeps-runs");
let storage = Storage::open(&path).await.unwrap();
// `ai_runs` declares an ON DELETE CASCADE relationship. Exercise the
// snapshot path with enforcement enabled so an accidental REPLACE
// (delete + insert) cannot silently erase an active run on restart.
storage
.with_conn(|conn| conn.execute_batch("PRAGMA foreign_keys = ON").map_err(|e| e.to_string()))
.await
.unwrap();
let mut conversation = ai_conversation("upsert-conv", "0000");
let run = ai_run("upsert-run", "upsert-conv", AiRunStatus::Running, "0000");
storage.save_ai_run_state(&conversation, &run).await.unwrap();
conversation.updated_at = "0001".to_string();
conversation.queued_input = Some("send later".to_string());
storage.save_ai_conversation(&conversation).await.unwrap();
let runs = storage.load_ai_runs().await.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].run_id, "upsert-run");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn terminal_ai_runs_are_capped_per_conversation_while_nonterminal_survive() {
// Reviewed finding (unbounded terminal run growth): save_ai_run /
// save_ai_run_state persist every terminal run and load_ai_runs loads
// the whole table at startup, but prune_ai_conversations only caps
// conversations and deliberately retains runs for the survivors - so
// repeated completed runs grew SQLite storage and recovery work
// forever. The storage layer now caps terminal history per
// conversation (keeping the newest few, which drive the row status
// badge after restart) and never touches recovery-relevant runs.
let path = temp_db_path("ai-terminal-runs-capped");
let storage = Storage::open(&path).await.unwrap();
// A non-terminal run must always survive - it is the recovery payload.
let conversation = ai_conversation("cap-conv", "0000");
storage
.save_ai_run_state(&conversation, &ai_run("active-run", "cap-conv", AiRunStatus::Running, "0000"))
.await
.unwrap();
// Repeated completed runs (normal use): older ones must be pruned.
for index in 0..5 {
let timestamp = format!("{index:04}");
storage
.save_ai_run(&ai_run(&format!("terminal-{index}"), "cap-conv", AiRunStatus::Completed, &timestamp))
.await
.unwrap();
}
let runs = storage.load_ai_runs().await.unwrap();
assert!(runs.iter().any(|run| run.run_id == "active-run"), "recovery-relevant run must survive");
let terminal: Vec<_> = runs.iter().filter(|run| run.status == AiRunStatus::Completed).collect();
assert_eq!(
terminal.len(),
KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION as usize,
"only the newest terminal runs per conversation survive"
);
assert!(terminal.iter().any(|run| run.run_id == "terminal-4"), "the newest terminal run is retained");
assert!(!runs.iter().any(|run| run.run_id == "terminal-0"), "the oldest terminal runs are pruned");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_roundtrips_queued_input() {
let path = temp_db_path("ai-conversation-queued-input-roundtrip");
let storage = Storage::open(&path).await.unwrap();
let mut conversation = ai_conversation("queued-conv", "0000");
conversation.queued_input = Some("run this after the current task".to_string());
storage.save_ai_conversation(&conversation).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, "queued-conv");
assert_eq!(loaded[0].queued_input.as_deref(), Some("run this after the current task"));
// Overwriting clears a stale queued input.
conversation.queued_input = None;
storage.save_ai_conversation(&conversation).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert!(loaded[0].queued_input.is_none());
let _ = std::fs::remove_file(path);
}
#[tokio::test] #[tokio::test]
async fn history_search_filters_connection_database_and_legacy_entries() { async fn history_search_filters_connection_database_and_legacy_entries() {
let path = temp_db_path("history-search-scope"); let path = temp_db_path("history-search-scope");
+11 -4
View File
@@ -369,10 +369,17 @@ pub async fn ai_stream(
}) })
.await; .await;
if let Err(_e) = result { if let Err(error) = result {
let error_chunk = // Preserve the existing POST/SSE lifecycle, but make failure an
AiStreamChunk { session_id: sid.clone(), delta: String::new(), reasoning_delta: None, done: true }; // explicit terminal payload so Web cannot mistake it for a
let _ = tx.send(serde_json::to_string(&error_chunk).unwrap_or_default()); // successful empty response.
let error_chunk = serde_json::json!({
"session_id": sid.clone(),
"delta": "",
"done": true,
"error": error,
});
let _ = tx.send(error_chunk.to_string());
} }
dbx_core::ai::unregister_stream(&sid).await; dbx_core::ai::unregister_stream(&sid).await;
+4 -1
View File
@@ -57,7 +57,10 @@ test("send() acquires the guard synchronously before the first await", () => {
const entryCheck = body.search(/\|\|\s*isGenerating\.value\)\s*return/); const entryCheck = body.search(/\|\|\s*isGenerating\.value\)\s*return/);
const guardSet = body.indexOf("isGenerating.value = true"); const guardSet = body.indexOf("isGenerating.value = true");
const firstAwait = body.indexOf("await"); // `await` as a whole word (not the substring inside e.g. the
// `awaiting_write_confirmation` run status), so the first suspension point is
// genuinely the first await statement.
const firstAwait = body.search(/\bawait\b/);
assert.notEqual(entryCheck, -1, "send() should early-return when isGenerating is already set"); assert.notEqual(entryCheck, -1, "send() should early-return when isGenerating is already set");
assert.notEqual(guardSet, -1, "send() should set the isGenerating guard"); assert.notEqual(guardSet, -1, "send() should set the isGenerating guard");
+6 -4
View File
@@ -61,15 +61,17 @@ test("assistant messages keep metadata aligned with the bubble and wrap long tex
test("AI request failures use localized backend diagnostics", () => { test("AI request failures use localized backend diagnostics", () => {
// The lookup is guarded (matching the `finally` block right below it) so that if // The lookup is guarded (matching the `finally` block right below it) so that if
// clearMessages()/selectConversation() already replaced messages.value while this // clearMessages()/selectConversation() already replaced the transcript while this
// request was still in flight, writing the failure text doesn't throw and silently // request was still in flight, writing the failure text doesn't throw and silently
// swallow the real error. See issue #5941. // swallow the real error. See issue #5941. (`runMessages` is the send pipeline's
assert.match(source, /const msg = messages\.value\[assistantIdx\];\s*\n\s*if \(msg\) msg\.content = `\$\{t\("ai\.requestFailed"\)\}\\n\\n\$\{translateBackendError\(t, message\)\}`;/); // array — `messages.value` for a visible conversation, the run's own array for a
// detached/background run.)
assert.match(source, /const msg = runMessages\[assistantIdx\];\s*\n\s*if \(msg\) msg\.content = `\$\{t\("ai\.requestFailed"\)\}\\n\\n\$\{translateBackendError\(t, message\)\}`;/);
}); });
test("AI analysis export keeps the connection that produced each assistant response", () => { test("AI analysis export keeps the connection that produced each assistant response", () => {
assert.match(source, /sourceConnectionName\?: string/); assert.match(source, /sourceConnectionName\?: string/);
assert.match(source, /messages\.value\.push\(\{ role: "assistant", content: "", sourceConnectionName: connection\.name \}\)/); assert.match(source, /runMessages\.push\(\{ role: "assistant", content: "", sourceConnectionName: connection\.name \}\)/);
assert.match(source, /connectionName: msg\.sourceConnectionName \?\? props\.connection\?\.name/); assert.match(source, /connectionName: msg\.sourceConnectionName \?\? props\.connection\?\.name/);
assert.match(source, /sourceConnectionName: m\.role === "assistant" \? conv\.connectionName : undefined/); assert.match(source, /sourceConnectionName: m\.role === "assistant" \? conv\.connectionName : undefined/);
}); });
+10 -4
View File
@@ -3,11 +3,17 @@ import { execFileSync } from "node:child_process";
import { test } from "vitest"; import { test } from "vitest";
test("i18n autofill parses locale roots with imported shorthand sections", () => { test("i18n autofill parses locale roots with imported shorthand sections", () => {
const output = execFileSync(process.execPath, [".github/scripts/i18n-autofill.mjs", "--dry-run", "--base-ref", "HEAD"], { // `--mock-translations`: the script's DeepSeek-backed translation step only
encoding: "utf8", // runs when a PR adds NEW zh-CN keys (here: the current branch's 38). CI
}); // has no DEEPSEEK_API_KEY, so this test asserts the parser/dry-run pipeline
// works with the deterministic mock instead of depending on a live API key.
const output = execFileSync(
process.execPath,
[".github/scripts/i18n-autofill.mjs", "--dry-run", "--base-ref", "HEAD", "--mock-translations"],
{ encoding: "utf8" },
);
// Accept every "no new keys / no changes needed" wording used across // Accept every "no new keys / no changes needed" wording used across
// recent PRs (e.g. mongo-clone, consul) plus the "found N new keys" path. // recent PRs (e.g. mongo-clone, consul) plus the "found N new keys" path.
assert.match(output, /(?:No new zh-CN i18n keys compared with HEAD|Found \d+ new zh-CN i18n key\(s\):|No locale files needed changes)/); assert.match(output, /(?:No new zh-CN i18n keys compared with HEAD|Found \d+ new zh-CN i18n key\(s\):|No locale files needed changes|Dry run completed)/);
}); });
+19
View File
@@ -286,6 +286,25 @@ pub async fn delete_ai_conversation(state: State<'_, Arc<AppState>>, id: String)
state.storage.delete_ai_conversation(&id).await state.storage.delete_ai_conversation(&id).await
} }
#[tauri::command]
pub async fn save_ai_run(state: State<'_, Arc<AppState>>, run: AiRun) -> Result<(), String> {
state.storage.save_ai_run(&run).await
}
#[tauri::command]
pub async fn save_ai_run_state(
state: State<'_, Arc<AppState>>,
conversation: AiConversation,
run: AiRun,
) -> Result<(), String> {
state.storage.save_ai_run_state(&conversation, &run).await
}
#[tauri::command]
pub async fn load_ai_runs(state: State<'_, Arc<AppState>>) -> Result<Vec<AiRun>, String> {
state.storage.load_ai_runs().await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
+3
View File
@@ -1804,6 +1804,9 @@ pub fn run() {
commands::ai::save_ai_conversation, commands::ai::save_ai_conversation,
commands::ai::load_ai_conversations, commands::ai::load_ai_conversations,
commands::ai::delete_ai_conversation, commands::ai::delete_ai_conversation,
commands::ai::save_ai_run,
commands::ai::save_ai_run_state,
commands::ai::load_ai_runs,
commands::ai_multi_config::save_ai_configs, commands::ai_multi_config::save_ai_configs,
commands::ai_multi_config::load_ai_configs, commands::ai_multi_config::load_ai_configs,
commands::ai_multi_config::set_default_ai_config, commands::ai_multi_config::set_default_ai_config,