Files
Abeautifulsnow 8539011f25 feat(ai): split Ask/Agent action menus with task-oriented Agent actions
* feat(ai): add task-oriented Agent actions with per-action contract

Introduce three Agent-native actions — query, exploreSchema,
executeAndExplain — so Agent mode surfaces task-oriented operations
instead of reusing Ask's SQL-producing action set.

- ai.ts: extend AiAction with the new actions; add ASK_ACTIONS /
  AGENT_ACTIONS and defaultActionForMode / isValidActionForMode
  helpers; raise maxTokens for the explanatory task actions.
- aiSkills.ts: add skill definitions (zh/en) for the three actions.
- aiAgentPlan.ts: task actions no longer emit a misleading
  "unsupported_action" execute step; they execute via the
  execute_query tool events, not the legacy client-side path.
- agent_loop.rs: per-action task-contract rules in both the prompt
  augmentation and repair paths (query -> execute & answer from real
  data; exploreSchema -> list_tables/get_columns; executeAndExplain
  -> run current SQL & explain).
- i18n (en/zh-CN/zh-TW): new action labels, placeholders, a
  generateNoExec label for Agent mode, and a task-oriented agent
  modeHint.

es/it/ja/pt-BR have no `ai` section and fall back to `en`, matching
existing coverage.

* feat(ai): split Ask/Agent action menus with mode-aware defaults

Wire the new Agent task actions into the assistant UI so each mode
shows a distinct, intent-matching action set.

- actionButtons is now a computed returning Ask or Agent buttons
  based on assistantMode. Agent menu: query / exploreSchema /
  executeAndExplain / generate (labeled "生成但不执行" via
  generateNoExec). Ask menu is unchanged.
- Switching mode lands on that mode's default (Ask -> generate,
  Agent -> query); the shared `generate` is not carried across
  because its label/semantics differ per mode. Vector DBs stay on
  `generate` since their action menu is hidden and they lack SQL
  tools (no execute_query contract).
- External Ask-style triggers (Fix with AI, Explain history) switch
  to Ask mode when invoked from Agent mode so the action is valid
  and the menu reflects what runs.
- Reset the active action to the mode default after each send.

* test(ai): cover Agent task-action contract and mode/action mapping

Lock in the deterministic parts of the Ask/Agent split so only the
LLM-behavior criteria need manual verification.

Backend (agent_loop.rs):
- query contract instructs the LLM to call execute_query and is not
  treated as a SQL-producing action.
- exploreSchema contract points to list_tables/get_columns and says
  not to execute data queries.
- executeAndExplain contract tells the LLM to run the current SQL.
- task actions do not require a SQL deliverable (a conclusion without
  a fenced SQL block still satisfies the contract).
- query repair prompt also targets execute_query.

Frontend (aiActions.spec.ts):
- defaultActionForMode: Ask -> generate, Agent -> query (not generate).
- isValidActionForMode: generate valid in both; Ask-only actions
  rejected in Agent mode and vice versa.
- ASK_ACTIONS / AGENT_ACTIONS composition (Agent menu starts with
  query and still offers generate).

* fix(ai): correct action menu on mode-switched triggers and i18n gaps

Issues found during review of the Ask/Agent action split:

- [#I01] triggerAction (Fix with AI / Explain history) invoked from
  Agent mode switched to Ask and set the action, but the mode-switch
  watch then reset activeAction to the Ask default ("generate"),
  overwriting the action just set — so the menu showed "Generate SQL"
  during a fix. Add a suppressModeActionReset flag so programmatic
  mode switches don't clobber the explicitly-set action.
- [#C01] Remove dead `unsupported_action` reason: buildAiAgentPlan no
  longer produces it, so drop the type variant and the unreachable
  skippedTitleKey case.
- [#I02] es/it/ja/pt-BR were missing the new Agent action keys
  (query/exploreSchema/executeAndExplain/generateNoExec) and their
  placeholders, causing English fallback in the Agent menu. Add the
  translations to match each locale's existing style.

* test(ai): align app-tests with task-action split

- aiSkills: add query/exploreSchema/executeAndExplain to the expected
  action set; broaden the id check (new ids are not _sql-suffixed) and
  relax the userInstruction assertion (schema inspection need not mention SQL).
- aiAgentPlan: drop the unsupported_action skipped step for non-generate
  actions (#C01 removed it from the plan and the reason union); add coverage
  for task-oriented Agent actions not driving client-side execution.

* test(ai): pin locale in dialect prompt spec

buildSystemPrompt selects zh/en copy via currentLocale(), which reflects the
host navigator.language. The spec asserts English strings, so it failed on
zh-CN machines (e.g. Windows with a Chinese system locale) while passing on
CI's en-US runners. Pin the locale to en in beforeAll so the assertions are
deterministic regardless of the host OS locale.
2026-07-06 14:41:39 +08:00

218 lines
7.3 KiB
TypeScript
Vendored

import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildAiAgentPlan } from "../../apps/desktop/src/lib/ai/aiAgentPlan.ts";
import type { AiAction, AiAssistantMode } from "../../apps/desktop/src/lib/ai/ai.ts";
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
function conn(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "c1",
name: "local-pg",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
database: "app_dev",
...overrides,
};
}
function planInput(
overrides: {
mode?: AiAssistantMode;
action?: AiAction;
instruction?: string;
assistantContent?: string;
connection?: ConnectionConfig;
} = {},
) {
return {
mode: overrides.mode ?? "agent",
action: overrides.action ?? "generate",
instruction: overrides.instruction ?? "查一下用户数量",
assistantContent: overrides.assistantContent ?? "```sql\nSELECT count(*) FROM users\n```",
connection: overrides.connection ?? conn(),
};
}
test("ask mode records generated SQL but skips execution", () => {
const plan = buildAiAgentPlan(planInput({ mode: "ask" }));
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
{ kind: "execute_sql", status: "skipped", reason: "ask_mode" },
]);
assert.equal(plan.executableSql, undefined);
});
test("agent mode auto-executes read SQL when the user asks to query", () => {
const plan = buildAiAgentPlan(planInput());
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
{
kind: "risk_check",
status: "done",
action: "auto_execute",
environment: "non_production",
category: "read",
reasons: [],
},
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
]);
assert.equal(plan.executableSql, "SELECT count(*) FROM users");
assert.equal(plan.handoffSql, "SELECT count(*) FROM users");
});
test("agent mode auto-executes table inventory metadata SQL for natural Chinese questions", () => {
const sql = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public' ORDER BY tablename;";
const plan = buildAiAgentPlan(
planInput({
instruction: "当前有哪些表",
assistantContent: `SQL\n\n\n\n\`\`\`sql\n${sql}\n\`\`\``,
}),
);
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql },
{
kind: "risk_check",
status: "done",
action: "auto_execute",
environment: "non_production",
category: "read",
reasons: [],
},
{ kind: "execute_sql", status: "pending", sql },
]);
assert.equal(plan.handoffSql, sql);
});
test("agent mode skips execution when the user explicitly asks not to run", () => {
const plan = buildAiAgentPlan(planInput({ instruction: "只生成 SQL,不要执行" }));
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
]);
assert.equal(plan.executableSql, undefined);
assert.equal(plan.handoffSql, undefined);
});
test("non-generate actions do not execute even in agent mode", () => {
const plan = buildAiAgentPlan(planInput({ action: "optimize", instruction: "优化这条 SQL" }));
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
]);
assert.equal(plan.executableSql, undefined);
assert.equal(plan.handoffSql, undefined);
});
test("task-oriented Agent actions do not drive client-side execution", () => {
for (const action of ["query", "exploreSchema", "executeAndExplain"] as AiAction[]) {
const plan = buildAiAgentPlan(planInput({ action, instruction: "查一下用户数量" }));
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
]);
assert.equal(plan.executableSql, undefined);
assert.equal(plan.handoffSql, undefined);
}
});
test("agent plan blocks dangerous SQL", () => {
const plan = buildAiAgentPlan(
planInput({
assistantContent: "```sql\nDROP TABLE users\n```",
}),
);
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
{
kind: "risk_check",
status: "done",
action: "block",
environment: "non_production",
category: "dangerous",
reasons: [],
},
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
]);
assert.equal(plan.executableSql, undefined);
assert.equal(plan.handoffSql, "DROP TABLE users");
});
test("agent plan requires confirmation for production writes", () => {
const plan = buildAiAgentPlan(
planInput({
assistantContent: "```sql\nINSERT INTO users(name) VALUES ('a')\n```",
connection: conn({ name: "prod-db", host: "10.0.0.9", database: "app_prod" }),
}),
);
assert.equal(plan.steps[1]?.kind, "risk_check");
assert.equal(plan.steps[1]?.status, "done");
if (plan.steps[1]?.kind === "risk_check") {
assert.equal(plan.steps[1].action, "confirm");
assert.equal(plan.steps[1].environment, "production");
assert.equal(plan.steps[1].category, "low_risk_write");
}
assert.deepEqual(plan.steps[2], {
kind: "execute_sql",
status: "skipped",
reason: "requires_confirmation",
});
assert.equal(plan.executableSql, undefined);
assert.equal(plan.handoffSql, "INSERT INTO users(name) VALUES ('a')");
});
test("agent plan skips execution when no SQL block is present", () => {
const plan = buildAiAgentPlan(planInput({ assistantContent: "我需要更多信息。" }));
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
]);
assert.equal(plan.executableSql, undefined);
});
test("agent plan ignores comment-only SQL blocks and executes the first real SQL block", () => {
const plan = buildAiAgentPlan(
planInput({
instruction: "查一下当前数据库里有哪些表",
assistantContent: ["当前数据库中的表:", "```sql", "-- 当前数据库中的表(仅从已加载的 Schema 上下文得知):", "-- public.ihli_data", "```", "若需查看该表数据,可执行:", "```sql", "SELECT * FROM ihli_data", "LIMIT 10;", "```"].join("\n"),
}),
);
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "done", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
{
kind: "risk_check",
status: "done",
action: "auto_execute",
environment: "non_production",
category: "read",
reasons: [],
},
{ kind: "execute_sql", status: "pending", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
]);
assert.equal(plan.handoffSql, "SELECT * FROM ihli_data\nLIMIT 10;");
});
test("agent plan treats comment-only SQL responses as no SQL", () => {
const plan = buildAiAgentPlan(
planInput({
assistantContent: "```sql\n-- public.ihli_data\n```",
}),
);
assert.deepEqual(plan.steps, [
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
]);
assert.equal(plan.handoffSql, undefined);
});