feat(ai): run agent sessions as background tasks
This commit is contained in:
1 parent
b37a1f8538
commit
eeeb4e5011
34 files changed
+3440
-184
No files matched your search
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
+240
-20
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "思考プロセス",
|
||||||
|
|||||||
@@ -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: "사고 과정",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "思考过程",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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}"), ×tamp)).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, ×tamp),
|
||||||
|
&ai_run(&format!("run-{index}"), &id, AiRunStatus::AwaitingWriteConfirmation, ×tamp),
|
||||||
|
)
|
||||||
|
.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}"), ×tamp)).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, ×tamp))
|
||||||
|
.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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)/);
|
||||||
});
|
});
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user