mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
* 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.
218 lines
7.3 KiB
TypeScript
Vendored
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);
|
|
});
|