fix(ai): require exact SQL confirmation for agent writes
This commit is contained in:
1 parent
f2e13922a2
commit
12da615daa
16 files changed
+626
-30
No files matched your search
@@ -1,11 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containsWriteSql, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative, type WriteSqlGrantParams } from "@/lib/ai/aiProposalDetect";
|
||||
import { containsWriteSql, isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative, type WriteSqlGrantParams } from "@/lib/ai/aiProposalDetect";
|
||||
import { aiSkillForAction } from "@/lib/ai/aiSkills";
|
||||
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock, countSqlCodeBlocks } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
|
||||
// ── looksLikeWriteSqlProposal ──────────────────────────────────────────────
|
||||
|
||||
describe("looksLikeWriteSqlProposal", () => {
|
||||
it("detects the backend-generated write confirmation proposal", () => {
|
||||
const proposal = ["This database change requires your explicit confirmation before DBX can execute it.", "", "```sql", "CREATE TABLE users (id INT);", "```", "", "Should I execute this SQL statement?"].join("\n");
|
||||
expect(looksLikeActionProposal(proposal)).toBe(true);
|
||||
expect(looksLikeWriteSqlProposal(proposal)).toBe(true);
|
||||
expect(extractSingleSqlCodeBlock(proposal)).toBe("CREATE TABLE users (id INT);");
|
||||
});
|
||||
|
||||
it("detects zh write-SQL proposal (CREATE in last line)", () => {
|
||||
expect(looksLikeWriteSqlProposal("需要我执行 CREATE TABLE users 吗?")).toBe(true);
|
||||
});
|
||||
@@ -102,15 +109,15 @@ describe("looksLikeWriteSqlProposal", () => {
|
||||
describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", () => {
|
||||
const writeProposal = {
|
||||
role: "assistant" as const,
|
||||
content: "已经分析了表结构。\n需要我执行这条 CREATE TABLE users 语句吗?",
|
||||
content: "已经分析了表结构。\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 CREATE TABLE users 语句吗?",
|
||||
};
|
||||
const insertProposal = {
|
||||
role: "assistant" as const,
|
||||
content: "I understand the schema.\nShould I run this INSERT for you?",
|
||||
content: "I understand the schema.\n```sql\nINSERT INTO users (id) VALUES (1);\n```\nShould I run this INSERT for you?",
|
||||
};
|
||||
const writeProposalEn = {
|
||||
role: "assistant" as const,
|
||||
content: "Should I execute this CREATE TABLE users?",
|
||||
content: "```sql\nCREATE TABLE users (id INT);\n```\nShould I execute this CREATE TABLE users?",
|
||||
};
|
||||
// Prompt-mirrored generic confirmation with SQL code block.
|
||||
const genericZhProposal = {
|
||||
@@ -145,6 +152,44 @@ describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", (
|
||||
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "创建表" }, genericZhProposal] }))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT grant for a generic write question without reviewable SQL", () => {
|
||||
const genericWriteQuestion = "需要我执行这条 INSERT 语句吗?";
|
||||
expect(looksLikeWriteSqlProposal(genericWriteQuestion)).toBe(true);
|
||||
expect(isActionableWriteSqlProposal(genericWriteQuestion)).toBe(false);
|
||||
expect(
|
||||
shouldGrantWriteSqlOnShortAffirmative(
|
||||
params({
|
||||
messages: [
|
||||
{ role: "user", content: "插入数据" },
|
||||
{ role: "assistant", content: genericWriteQuestion },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("grants for a backend-generated localized confirmation via its structural kind", () => {
|
||||
// The exact wording DBX renders from i18n (en): it does NOT contain an
|
||||
// English/Chinese ask phrase, so the text detectors reject it.
|
||||
const localizedEn = "This SQL changes data or schema. Review it carefully before execution.\n\n```sql\nCREATE TABLE users (id INT);\n```\n\nExecute this SQL once?";
|
||||
expect(looksLikeActionProposal(localizedEn)).toBe(false);
|
||||
expect(isActionableWriteSqlProposal(localizedEn)).toBe(false);
|
||||
// The backend marks the message, making it actionable without phrase detection.
|
||||
expect(isActionableWriteProposalMessage({ role: "assistant", content: localizedEn, kind: "writeSqlConfirmation" })).toBe(true);
|
||||
expect(
|
||||
shouldGrantWriteSqlOnShortAffirmative(
|
||||
params({
|
||||
messages: [
|
||||
{ role: "user", content: "创建表" },
|
||||
{ role: "assistant", content: localizedEn, kind: "writeSqlConfirmation" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// A production-block message must never be treated as a confirmable write.
|
||||
expect(isActionableWriteProposalMessage({ role: "assistant", content: localizedEn, kind: "productionWriteBlocked" })).toBe(false);
|
||||
});
|
||||
|
||||
it("skips contextSummary messages to find the assistant message", () => {
|
||||
expect(
|
||||
shouldGrantWriteSqlOnShortAffirmative(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
|
||||
/**
|
||||
* Pure detectors for recognizing AI assistant action proposals and user
|
||||
* short replies (affirmative / negative). Used by the chat UI to render
|
||||
@@ -235,6 +237,35 @@ export function looksLikeWriteSqlProposal(content: string): boolean {
|
||||
return lastLineMentionsWriteSql(lastLine, isZh, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* A write proposal is actionable only when the user can review exactly one
|
||||
* SQL statement. A generic “execute this INSERT?” prompt cannot safely bind
|
||||
* the next agent run, so the UI must not present it as an executable action.
|
||||
*/
|
||||
export function isActionableWriteSqlProposal(content: string): boolean {
|
||||
return looksLikeWriteSqlProposal(content) && extractSingleSqlCodeBlock(content) !== undefined;
|
||||
}
|
||||
|
||||
/** A chat message that the write-confirmation gate inspects. */
|
||||
export interface WriteSqlGrantMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a chat message can bind the next agent run to one exact SQL
|
||||
* statement. Backend-generated confirmations are marked with a structural
|
||||
* `kind` and carry exactly one SQL block, so they are actionable regardless of
|
||||
* the locale their localized wording is in; model-authored proposals still
|
||||
* have to pass the English/Chinese text detectors.
|
||||
*/
|
||||
export function isActionableWriteProposalMessage(msg: WriteSqlGrantMessage): boolean {
|
||||
if (msg.role !== "assistant" || !msg.content) return false;
|
||||
if (msg.kind === "writeSqlConfirmation") return extractSingleSqlCodeBlock(msg.content) !== undefined;
|
||||
return isActionableWriteSqlProposal(msg.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the proposal line explicitly references a write/DDL
|
||||
* SQL operation (not a read-only action that happens to coexist with a
|
||||
@@ -279,7 +310,7 @@ export interface WriteSqlGrantParams {
|
||||
* The function scans backward from the last message, skipping
|
||||
* contextSummary entries, and stops at the first user message.
|
||||
*/
|
||||
messages: Array<{ role: "user" | "assistant"; content: string; kind?: "contextSummary" }>;
|
||||
messages: WriteSqlGrantMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,7 +327,7 @@ export function shouldGrantWriteSqlOnShortAffirmative(params: WriteSqlGrantParam
|
||||
for (let i = params.messages.length - 1; i >= 0; i--) {
|
||||
const msg = params.messages[i];
|
||||
if (msg.kind === "contextSummary") continue;
|
||||
if (msg.role === "assistant" && msg.content && looksLikeWriteSqlProposal(msg.content)) return true;
|
||||
if (isActionableWriteProposalMessage(msg)) return true;
|
||||
if (msg.role === "user") return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -438,6 +438,8 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
|
||||
export type AgentEvent =
|
||||
| { type: "turn_start"; turn: number }
|
||||
| { type: "text_delta"; delta: string }
|
||||
| { type: "write_sql_confirmation_required"; sql: string }
|
||||
| { type: "production_write_blocked"; sql: string }
|
||||
| { type: "reasoning_delta"; delta: string }
|
||||
| {
|
||||
type: "tool_call_start";
|
||||
@@ -850,7 +852,7 @@ export interface AiChatMessage {
|
||||
content: string;
|
||||
mentions?: unknown[];
|
||||
reasoning?: string;
|
||||
kind?: "contextSummary";
|
||||
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
|
||||
Reference in new issue
Block a user