feat(ai): add opt-in auto intent routing for the assistant

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-17 12:53:55 +08:00
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 />
@@ -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");
});
});