feat(ai): add opt-in auto intent routing for the assistant
This commit is contained in:
1 parent
023f0074bc
commit
3f3c21aae9
26 files changed
+1454
-16
No files matched your search
@@ -38,6 +38,7 @@ import {
|
||||
Table2,
|
||||
Play,
|
||||
Square,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
Terminal,
|
||||
@@ -79,8 +80,10 @@ import {
|
||||
runAgentStream,
|
||||
isVectorDbType,
|
||||
isValidActionForMode,
|
||||
isAutoActionSelection,
|
||||
defaultActionForMode,
|
||||
type AiAction,
|
||||
type AiActionSelection,
|
||||
type AiAssistantMode,
|
||||
type AiCsvFileContext,
|
||||
type AiTextAttachmentEncoding,
|
||||
@@ -167,6 +170,7 @@ import { handleAiTableReferenceDropEvent } from "@/lib/ai/aiTableReferenceDrop";
|
||||
import { DBX_TABLE_REFERENCE_DROP_EVENT, clearActiveTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
|
||||
import { canSubmitAiPrompt, isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
|
||||
import { isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
|
||||
import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
|
||||
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
@@ -262,6 +266,13 @@ interface ChatMessage {
|
||||
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
|
||||
/** Per-message token stats from the last agent run; ephemeral, not persisted. */
|
||||
tokens?: { input: number; output: number };
|
||||
/**
|
||||
* Set on the user message that was sent while the picker stood on `auto`,
|
||||
* together with the concrete action the router resolved (#9118). Ephemeral
|
||||
* (like `tokens`): it only drives the "Auto · <action>" chip.
|
||||
*/
|
||||
routedFrom?: "auto";
|
||||
routedAction?: AiAction;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -286,7 +297,12 @@ const prompt = ref("");
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const isGenerating = ref(false);
|
||||
const scrollRef = ref<InstanceType<typeof ScrollArea> | null>(null);
|
||||
const activeAction = ref<AiAction>("general");
|
||||
// Stays a concrete action until the AI config loads: whether new conversations
|
||||
// land on the `auto` entry is decided by the Settings > AI toggle (see
|
||||
// `resolveDefaultActionSelection`).
|
||||
const activeAction = ref<AiActionSelection>("general");
|
||||
/** True while the Auto slow path (intent classifier) is in flight for the visible conversation. */
|
||||
const routingInFlight = ref(false);
|
||||
const assistantMode = ref<AiAssistantMode>("ask");
|
||||
// The selection is loaded asynchronously. Apply it once when this panel mounts,
|
||||
// but do not let later setting changes alter an active conversation.
|
||||
@@ -299,6 +315,9 @@ watch(
|
||||
(loaded) => {
|
||||
if (loaded && !defaultModeInitialized) {
|
||||
assistantMode.value = settings.defaultAiMode;
|
||||
// Same apply-once rule as the mode: later setting changes must not
|
||||
// disturb an active conversation.
|
||||
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
|
||||
defaultModeInitialized = true;
|
||||
}
|
||||
},
|
||||
@@ -347,14 +366,14 @@ const unreadConversations = reactive(new Set<string>());
|
||||
* occupies it. Persisted via `AiConversation.queuedInput` and restored after a
|
||||
* restart. The mode/action are in-memory only (they default to the current
|
||||
* view after a restart). */
|
||||
type QueuedConversationInput = { text: string; mode: AiAssistantMode; action: AiAction };
|
||||
type QueuedConversationInput = { text: string; mode: AiAssistantMode; action: AiActionSelection };
|
||||
const queuedInputs = reactive(new Map<string, QueuedConversationInput>());
|
||||
|
||||
/** Auto-send/retry work waiting to enter the normal send pipeline. More than
|
||||
* one background run can settle in the same event turn; this must be FIFO, not
|
||||
* a single "next send" slot, or the later completion silently drops the first
|
||||
* conversation's queued input. */
|
||||
type PendingAutoSend = { conversationId: string; text: string; messages: ChatMessage[]; mode: AiAssistantMode; action: AiAction };
|
||||
type PendingAutoSend = { conversationId: string; text: string; messages: ChatMessage[]; mode: AiAssistantMode; action: AiActionSelection };
|
||||
const pendingAutoSends: PendingAutoSend[] = [];
|
||||
|
||||
/** Highest event `seq` the user has read per conversation (parent PRD §8). Set
|
||||
@@ -1105,14 +1124,23 @@ const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT = 50;
|
||||
const AI_SQL_FILE_CONTEXT_MAX_CHARS = 12_000;
|
||||
|
||||
interface AiActionButton {
|
||||
action: AiAction;
|
||||
action: AiActionSelection;
|
||||
icon: Component;
|
||||
/** i18n key for the menu label. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `auto` entry (#9118) heads both lists: it keeps every explicit action
|
||||
* available while giving users one entry that routes the request to whichever
|
||||
* action fits (see `resolveAutoAction`). It is UI-only — `ASK_ACTIONS` /
|
||||
* `AGENT_ACTIONS` stay free of it.
|
||||
*/
|
||||
const autoActionButton: AiActionButton = { action: "auto", icon: Sparkles, key: "ai.actions.auto" };
|
||||
|
||||
/** Ask-mode actions: SQL-producing, never auto-run. */
|
||||
const askActionButtons: AiActionButton[] = [
|
||||
autoActionButton,
|
||||
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
|
||||
{ action: "generate", icon: Wand2, key: "ai.actions.generate" },
|
||||
{ action: "explain", icon: HelpCircle, key: "ai.actions.explain" },
|
||||
@@ -1124,6 +1152,7 @@ const askActionButtons: AiActionButton[] = [
|
||||
|
||||
/** Agent-mode actions: task-oriented, drive tool use and real results. */
|
||||
const agentActionButtons: AiActionButton[] = [
|
||||
autoActionButton,
|
||||
{ action: "general", icon: MessageSquarePlus, key: "ai.actions.general" },
|
||||
{ action: "query", icon: Search, key: "ai.actions.query" },
|
||||
{ action: "exploreSchema", icon: Table2, key: "ai.actions.exploreSchema" },
|
||||
@@ -1142,8 +1171,18 @@ function resolveDefaultAction(mode: AiAssistantMode): AiAction {
|
||||
return defaultActionForMode(mode);
|
||||
}
|
||||
|
||||
// The Settings > AI toggle lets new conversations land on the `auto` picker
|
||||
// entry (#9118); routing still resolves to a concrete action before any
|
||||
// request. Vector DBs keep the concrete `generate` action because their action
|
||||
// menu is hidden entirely.
|
||||
function resolveDefaultActionSelection(mode: AiAssistantMode): AiActionSelection {
|
||||
if (settings.defaultAutoRouting && !(props.connection && isVectorDbType(props.connection.db_type))) return "auto";
|
||||
return resolveDefaultAction(mode);
|
||||
}
|
||||
|
||||
// Switching mode is a deliberate context change: land on that mode's default action so the
|
||||
// menu and behavior match the new intent. The shared `general` action is the default.
|
||||
// menu and behavior match the new intent. That default is the mode's concrete action, or
|
||||
// `auto` when Settings > AI enables it (#9118).
|
||||
//
|
||||
// `triggerAction` may set the action itself after programmatically switching mode (e.g. "Fix
|
||||
// with AI" invoked from Agent mode); `suppressModeActionReset` tells this watch to skip the
|
||||
@@ -1154,7 +1193,7 @@ watch(assistantMode, (mode) => {
|
||||
suppressModeActionReset = false;
|
||||
return;
|
||||
}
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -1169,7 +1208,7 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectAction(action: AiAction) {
|
||||
function selectAction(action: AiActionSelection) {
|
||||
activeAction.value = action;
|
||||
if (action === "fix" && props.tab?.result) {
|
||||
if (isQueryExecutionErrorResult(props.tab.result)) {
|
||||
@@ -1179,6 +1218,64 @@ function selectAction(action: AiAction) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Menu label of a concrete action, mapped through either mode's button list. */
|
||||
function actionLabelFor(action: AiAction | null | undefined): string {
|
||||
if (!action) return "";
|
||||
const button = [...actionButtons.value, ...askActionButtons, ...agentActionButtons].find((item) => item.action === action);
|
||||
return button ? t(button.key) : action;
|
||||
}
|
||||
|
||||
/** The action the router resolved for an `auto` message, if any (drives the chip). */
|
||||
function routedActionOf(message: ChatMessage): AiAction | null {
|
||||
return message.routedFrom === "auto" && message.routedAction ? message.routedAction : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Auto · <action>" chip switches the picker to the action that was routed
|
||||
* for that message, so the user can keep iterating explicitly. The routed action
|
||||
* is always valid in the mode it was routed in; when the panel now stands in the
|
||||
* other mode, switch there first (the `suppressModeActionReset` handshake keeps
|
||||
* the mode-switch watch from overwriting the action we just set).
|
||||
*/
|
||||
function switchToRoutedAction(action: AiAction | null | undefined) {
|
||||
if (!action) return;
|
||||
if (!isValidActionForMode(action, assistantMode.value)) {
|
||||
suppressModeActionReset = true;
|
||||
assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";
|
||||
}
|
||||
activeAction.value = action;
|
||||
}
|
||||
|
||||
/** Mirrors `buildAiContext`'s `lastError`: only a real failed execution counts. */
|
||||
function tabHasLastError(): boolean {
|
||||
const result = props.tab?.result;
|
||||
return !!result && isQueryExecutionErrorResult(result) && result.rows[0]?.[0] != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `auto` picker entry into one concrete action (#9118), before any
|
||||
* request is assembled: the rule layer answers instantly, and only a rule miss
|
||||
* pays for a single tiny classifier completion (bounded by
|
||||
* `INTENT_CLASSIFY_TIMEOUT_MS`). The classifier never throws and falls back to
|
||||
* the mode default, so an unavailable/slow model cannot fail the send.
|
||||
*
|
||||
* `showProgress` is only true for the conversation actually on screen — a
|
||||
* background auto-send must not flash "识别中" over an unrelated chat.
|
||||
*/
|
||||
async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction> {
|
||||
const input: AiIntentRouteInput = { text, mode, hasCurrentSql: !!props.tab?.sql.trim(), hasLastError: tabHasLastError() };
|
||||
const byRules = routeIntentByRules(input);
|
||||
if (byRules) return byRules;
|
||||
const config = activeFullConfig.value;
|
||||
if (!config) return resolveDefaultAction(mode);
|
||||
if (showProgress) routingInFlight.value = true;
|
||||
try {
|
||||
return await classifyIntentByLlm(input, { config });
|
||||
} finally {
|
||||
if (showProgress) routingInFlight.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages visible in the chat UI (excludes hidden context summaries). */
|
||||
const visibleMessages = computed(() => messages.value.filter((m) => m.kind !== "contextSummary"));
|
||||
|
||||
@@ -1341,7 +1438,8 @@ function sendProposalReply(positive: boolean) {
|
||||
send();
|
||||
}
|
||||
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
// `auto` has no per-action placeholder of its own, so it reuses the general one.
|
||||
const activePlaceholder = computed(() => `${t(`ai.placeholders.${isAutoActionSelection(activeAction.value) ? "general" : activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`);
|
||||
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
|
||||
const codeSnapshotOpen = ref(false);
|
||||
@@ -1367,14 +1465,14 @@ const modeActionTriggerLabel = computed(() => {
|
||||
});
|
||||
|
||||
function switchModeActionTab(mode: "ask" | "agent") {
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
if (assistantMode.value !== mode) {
|
||||
// Set the mode after the action so the tab label and picker stay aligned.
|
||||
assistantMode.value = mode;
|
||||
}
|
||||
}
|
||||
|
||||
function selectModeActionItem(action: AiAction) {
|
||||
function selectModeActionItem(action: AiActionSelection) {
|
||||
// Vector databases only support generation; keep this constraint at the selection boundary.
|
||||
if (!showActionButtons.value) return;
|
||||
selectAction(action);
|
||||
@@ -2962,7 +3060,8 @@ async function send() {
|
||||
userText: text,
|
||||
});
|
||||
|
||||
runMessages.push({ role: "user", content: text, mentions: selectedMessageMentions(selectedTableMentions, selectedSqlFiles, csvAttachments, imageAttachments), csvAttachments, imageAttachments });
|
||||
const userMessage: ChatMessage = { role: "user", content: text, mentions: selectedMessageMentions(selectedTableMentions, selectedSqlFiles, csvAttachments, imageAttachments), csvAttachments, imageAttachments };
|
||||
runMessages.push(userMessage);
|
||||
if (!auto) {
|
||||
// Save to prompt history (deduplicate consecutive duplicates)
|
||||
if (text && promptHistory.value[0] !== text) {
|
||||
@@ -2979,8 +3078,41 @@ async function send() {
|
||||
}
|
||||
if (autoSendVisible) scrollToBottom({ force: true });
|
||||
|
||||
const requestedAction = auto ? auto.action : activeAction.value;
|
||||
const requestedSelection: AiActionSelection = auto ? auto.action : activeAction.value;
|
||||
const requestedMode = auto ? auto.mode : assistantMode.value;
|
||||
// A confirmed-write turn (the ✅ reply, or the segment that resumes an
|
||||
// `awaiting_write_confirmation` run) is a continuation of the pending proposal,
|
||||
// not a new user request: its reply text is component copy, so the Auto router
|
||||
// must not classify it. Keeping the mode default there preserves the pre-Auto
|
||||
// confirmation behavior (`taskContract.action` = the picker's default) instead
|
||||
// of letting a guess describe the wrong task to the backend.
|
||||
const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;
|
||||
// The "auto" picker entry is resolved HERE, before any request is assembled:
|
||||
// `taskContract.action` must be a concrete action (the backend interpolates it
|
||||
// into its system prompt and uses it for final-answer contract validation), and
|
||||
// `aiSkillForAction` throws on unknown actions. Explicit selections skip the
|
||||
// router entirely, so their behavior is unchanged (#9118).
|
||||
let requestedAction: AiAction;
|
||||
if (!isAutoActionSelection(requestedSelection)) {
|
||||
requestedAction = requestedSelection;
|
||||
} else if (confirmationContinuation) {
|
||||
// Nothing was routed here, so no "Auto · …" chip either (the proposal card
|
||||
// already explains what this turn does).
|
||||
requestedAction = resolveDefaultAction(requestedMode);
|
||||
} else {
|
||||
requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible());
|
||||
// Keep the outcome on the message so the "Auto · <action>" chip can explain
|
||||
// (and re-select) what the router chose.
|
||||
userMessage.routedFrom = "auto";
|
||||
userMessage.routedAction = requestedAction;
|
||||
}
|
||||
// Superseded while the classifier ran (stop/clear/switch): nothing has been
|
||||
// sent to the backend yet, so bail the same way the template-load wait does.
|
||||
if (!generationCanContinue()) {
|
||||
clearPendingWriteGrant();
|
||||
resolveDetachedRunSettled();
|
||||
return;
|
||||
}
|
||||
// Detect user-typed short confirmation (e.g. "可以"/"go ahead") as an alternative
|
||||
// path to the proposal ✅ button. Delegates to the shared pure function so the
|
||||
// component and its unit tests share the same gating logic.
|
||||
@@ -3623,6 +3755,11 @@ function resetPendingRequestState() {
|
||||
stopStatusTimer();
|
||||
generationStatus.value = createGenerationStatus(Date.now());
|
||||
statusNow.value = Date.now();
|
||||
// Abandon-path cleanup for the Auto router indicator too (#9118): the classifier
|
||||
// promise clears this in its own `finally` (normal path), but a clear/switch/
|
||||
// unmount must drop it synchronously, or "识别中" stays on screen in the next
|
||||
// conversation until that bounded call settles.
|
||||
routingInFlight.value = false;
|
||||
}
|
||||
|
||||
// `alreadyCancelledSessionId`: the session id a caller (cancelStream()) has
|
||||
@@ -4204,10 +4341,11 @@ function discardRecoveredDraft() {
|
||||
function startNewChat() {
|
||||
clearMessages();
|
||||
showConversationList.value = false;
|
||||
// A fresh conversation starts from the configured default mode.
|
||||
// A fresh conversation starts from the configured default mode and, when the
|
||||
// Settings > AI toggle says so, from the `auto` picker entry (#9118).
|
||||
const mode = settings.defaultAiMode;
|
||||
assistantMode.value = mode;
|
||||
activeAction.value = resolveDefaultAction(mode);
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
}
|
||||
|
||||
/** Send-button dispatcher: with an active run on the visible conversation the
|
||||
@@ -4841,6 +4979,21 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
<div v-if="msg.content" data-ai-user-message-content class="whitespace-pre-wrap">{{ msg.content }}</div>
|
||||
</div>
|
||||
<!-- "Auto" routing outcome (#9118): which action the router picked for
|
||||
this message, clickable to continue with that explicit action. -->
|
||||
<div v-if="routedActionOf(msg)" class="mt-1 flex justify-end">
|
||||
<button
|
||||
data-ai-auto-routed-chip
|
||||
type="button"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded border border-border/70 bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="t('ai.routing.switchTo', { action: actionLabelFor(routedActionOf(msg)) })"
|
||||
:aria-label="t('ai.routing.switchTo', { action: actionLabelFor(routedActionOf(msg)) })"
|
||||
@click="switchToRoutedAction(routedActionOf(msg))"
|
||||
>
|
||||
<Sparkles class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="canCopyMessage(msg)" class="mt-1 flex justify-end">
|
||||
<button
|
||||
data-ai-message-copy="user"
|
||||
@@ -4984,6 +5137,15 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- "Auto" routing in flight (#9118). Only the slow path (classifier)
|
||||
shows this — the rule layer resolves instantly. Rendered while the
|
||||
user message is already visible and before the assistant
|
||||
placeholder exists, so the send never looks stuck. -->
|
||||
<div v-if="routingInFlight" data-ai-auto-routing class="flex min-w-0 items-center gap-[7px] text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<span class="truncate">{{ t("ai.routing.recognizing") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Live generation-status line (Issue #6743 feature 1). Replaces the old
|
||||
"Thinking..." placeholder and covers the WHOLE generation period
|
||||
(`v-if="isGenerating"`), not just the wait for the first token. The
|
||||
|
||||
@@ -8315,6 +8315,22 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default auto intent routing (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="ai-default-auto-routing">
|
||||
{{ t("ai.defaultAutoRouting") }}
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("ai.defaultAutoRoutingDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="ai-default-auto-routing" :model-value="settingsStore.defaultAutoRouting" @update:model-value="(value) => settingsStore.setDefaultAutoRouting(Boolean(value))" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore last AI conversation (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// #9118: the AI assistant exposes one "Auto" entry at the head of both action
|
||||
// menus. These assertions pin the parts of the wiring that a behavioral unit
|
||||
// test cannot reach (the component owns a large amount of shared state):
|
||||
//
|
||||
// - every explicit action stays in the menu, "auto" is only prepended;
|
||||
// - the selection starts concrete; Auto is preselected on new conversations
|
||||
// only through the Settings > AI "default auto routing" toggle;
|
||||
// - "auto" is resolved to a concrete action BEFORE the request is assembled, so
|
||||
// `taskContract.action` never carries it (the backend interpolates that value
|
||||
// into its system prompt and uses it for final-answer contract validation);
|
||||
// - the vector-DB branch (hidden picker, forced `generate`) is untouched;
|
||||
// - external explicit triggers (`triggerAction`) bypass the router.
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function bodyOf(fnSignature: string): string {
|
||||
const start = source.indexOf(fnSignature);
|
||||
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
|
||||
const braceStart = source.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let i = braceStart; i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(braceStart, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
|
||||
}
|
||||
|
||||
describe("Auto picker entry", () => {
|
||||
it("heads both action lists without removing any explicit action", () => {
|
||||
const askList = source.slice(source.indexOf("const askActionButtons: AiActionButton[] = ["), source.indexOf("const agentActionButtons: AiActionButton[] = ["));
|
||||
const agentList = source.slice(source.indexOf("const agentActionButtons: AiActionButton[] = ["), source.indexOf("const actionButtons = computed"));
|
||||
|
||||
for (const [label, list] of [
|
||||
["ask", askList],
|
||||
["agent", agentList],
|
||||
] as const) {
|
||||
const autoIdx = list.indexOf("autoActionButton,");
|
||||
expect(autoIdx, `${label}: auto entry`).toBeGreaterThanOrEqual(0);
|
||||
expect(autoIdx, `${label}: auto must be first`).toBeLessThan(list.indexOf('action: "general"'));
|
||||
// The pre-existing actions are all still present.
|
||||
expect(list, `${label}: general`).toContain('action: "general"');
|
||||
expect(list, `${label}: generate`).toContain('action: "generate"');
|
||||
}
|
||||
expect(askList).toContain('action: "sampleData"');
|
||||
expect(askList).toContain('action: "convert"');
|
||||
expect(agentList).toContain('action: "executeAndExplain"');
|
||||
expect(agentList).toContain('action: "exploreSchema"');
|
||||
expect(agentList).toContain('action: "query"');
|
||||
});
|
||||
|
||||
it("widens the picker type instead of the transport action union", () => {
|
||||
// The menu entry uses the UI-only `AiActionSelection`; `AiAction` (what the
|
||||
// request carries) stays a concrete action.
|
||||
const buttonInterface = bodyOf("interface AiActionButton {");
|
||||
expect(buttonInterface).toContain("action: AiActionSelection;");
|
||||
expect(buttonInterface).not.toContain("action: AiAction;");
|
||||
expect(source).toContain('import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";');
|
||||
expect(source).toContain("type AiActionSelection,");
|
||||
});
|
||||
|
||||
it("keeps resolveDefaultAction concrete; auto preselection flows only through the settings toggle", () => {
|
||||
expect(source).toContain('const activeAction = ref<AiActionSelection>("general");');
|
||||
const resolveDefault = bodyOf("function resolveDefaultAction(mode: AiAssistantMode): AiAction");
|
||||
expect(resolveDefault).not.toContain("auto");
|
||||
expect(resolveDefault).toContain("defaultActionForMode(mode)");
|
||||
|
||||
const resolveSelection = bodyOf("function resolveDefaultActionSelection(mode: AiAssistantMode): AiActionSelection");
|
||||
// Auto only when the Settings > AI toggle is on, and never for vector DBs:
|
||||
// their picker is hidden and the task contract must not invite querying.
|
||||
expect(resolveSelection).toContain("settings.defaultAutoRouting");
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeGreaterThanOrEqual(0);
|
||||
expect(resolveSelection.indexOf('return "auto";')).toBeLessThan(resolveSelection.indexOf("resolveDefaultAction(mode)"));
|
||||
expect(resolveSelection).toContain("isVectorDbType(props.connection.db_type)");
|
||||
});
|
||||
|
||||
it("lands new conversations, mode switches and mount init on the settings-driven default", () => {
|
||||
expect(bodyOf("function startNewChat()")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf("watch(assistantMode, (mode) => {")).toContain("resolveDefaultActionSelection(mode);");
|
||||
expect(bodyOf('function switchModeActionTab(mode: "ask" | "agent") {')).toContain("resolveDefaultActionSelection(mode);");
|
||||
// Mount init applies the default exactly once (apply-once rule shared with
|
||||
// the default mode). The picker is panel-scoped: no restore path
|
||||
// (`selectConversation`) writes it, so the settings-driven default survives
|
||||
// restoring the last conversation.
|
||||
expect(source).toContain("activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);");
|
||||
});
|
||||
|
||||
it("hides the action list for vector databases and still forces generate there", () => {
|
||||
const listStart = source.indexOf("<!-- Action list -->");
|
||||
const pickerList = source.slice(listStart, source.indexOf("</PopoverContent>", listStart));
|
||||
expect(pickerList).toContain('v-for="button in actionButtons"');
|
||||
// The list (Auto included) only renders when the action picker is shown.
|
||||
const templateGuardIdx = source.lastIndexOf('<template v-if="showActionButtons">', listStart);
|
||||
expect(templateGuardIdx).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const vectorWatchStart = source.indexOf("() => props.connection?.db_type,");
|
||||
const vectorWatch = source.slice(vectorWatchStart, source.indexOf("{ immediate: true },", vectorWatchStart));
|
||||
expect(vectorWatch).toContain('activeAction.value = "generate";');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto routing at send time", () => {
|
||||
it("resolves the selection before the request reaches the backend", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
const routingIdx = sendBody.indexOf("await resolveAutoAction(");
|
||||
const streamIdx = sendBody.indexOf("await runAgentStream(");
|
||||
const contractIdx = sendBody.indexOf("action: requestedAction,");
|
||||
expect(routingIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(streamIdx).toBeGreaterThan(routingIdx);
|
||||
expect(contractIdx).toBeGreaterThan(routingIdx);
|
||||
// Auto is only routed when it was actually selected; every explicit
|
||||
// selection is passed through untouched.
|
||||
expect(sendBody).toContain("requestedAction = requestedSelection;");
|
||||
expect(sendBody).toContain("requestedAction = await resolveAutoAction(text, requestedMode, runIsVisible());");
|
||||
// A confirmed-write turn replies with component copy, so the router must not
|
||||
// run on it: the mode default keeps the pre-Auto confirmation contract.
|
||||
expect(sendBody).toContain("const confirmationContinuation = allowWriteSqlForNextRun || resumingConfirmedWrite;");
|
||||
expect(sendBody).toContain("requestedAction = resolveDefaultAction(requestedMode);");
|
||||
// A stop/clear/switch that lands during the classifier wait must not start
|
||||
// a request nobody can reach.
|
||||
expect(sendBody.indexOf("if (!generationCanContinue()) {", routingIdx)).toBeGreaterThan(routingIdx);
|
||||
});
|
||||
|
||||
it("records the routing outcome on the user message and shows the recognizing indicator", () => {
|
||||
const sendBody = bodyOf("async function send()");
|
||||
expect(sendBody).toContain('userMessage.routedFrom = "auto";');
|
||||
expect(sendBody).toContain("userMessage.routedAction = requestedAction;");
|
||||
|
||||
const resolveBody = bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>");
|
||||
// Zero-latency rule layer first; the classifier (network) only on a miss.
|
||||
expect(resolveBody.indexOf("routeIntentByRules(input)")).toBeLessThan(resolveBody.indexOf("classifyIntentByLlm(input,"));
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = true;");
|
||||
expect(resolveBody).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
expect(source).toContain("data-ai-auto-routing");
|
||||
expect(source).toContain('t("ai.routing.recognizing")');
|
||||
});
|
||||
|
||||
it("renders the Auto chip on the routed message and switches back to the explicit action", () => {
|
||||
expect(source).toContain("data-ai-auto-routed-chip");
|
||||
expect(source).toContain('t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) })');
|
||||
|
||||
const switchBody = bodyOf("function switchToRoutedAction(action: AiAction | null | undefined)");
|
||||
// Reuses the mode-switch suppression handshake so the watch cannot reset the
|
||||
// action right after the chip set it.
|
||||
expect(switchBody.indexOf("suppressModeActionReset = true;")).toBeLessThan(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";'));
|
||||
expect(switchBody.indexOf('assistantMode.value = isValidActionForMode(action, "ask") ? "ask" : "agent";')).toBeLessThan(switchBody.indexOf("activeAction.value = action;"));
|
||||
});
|
||||
|
||||
it("clears the recognizing indicator on the abandon path, not just the classifier's finally", () => {
|
||||
// Dual-path rule for per-request transient state (see
|
||||
// AiAssistant.clearAndCancel.spec.ts): the classifier's `finally` covers the
|
||||
// normal path, so clearMessages()/selectConversation()/onUnmounted must drop
|
||||
// the flag synchronously via resetPendingRequestState().
|
||||
expect(bodyOf("function resetPendingRequestState()")).toContain("routingInFlight.value = false;");
|
||||
expect(bodyOf("async function resolveAutoAction(text: string, mode: AiAssistantMode, showProgress: boolean): Promise<AiAction>")).toContain("if (showProgress) routingInFlight.value = false;");
|
||||
});
|
||||
|
||||
it("keeps external explicit triggers on the direct path", () => {
|
||||
const triggerBody = bodyOf("function triggerAction(action: AiAction, instruction?: string)");
|
||||
expect(triggerBody).toContain("activeAction.value = action;");
|
||||
expect(triggerBody).not.toContain("resolveAutoAction");
|
||||
expect(triggerBody).not.toContain("routeIntentByRules");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user