fix(ai): require exact SQL confirmation for agent writes

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-21 10:36:17 +08:00
1 parent f2e13922a2
commit 12da615daa
16 files changed
+626 -30

No files matched your search

@@ -132,7 +132,7 @@ import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } fr
import { copyToClipboard } from "@/lib/common/clipboard";
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/ai/aiTableMentions";
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
import { looksLikeActionProposal, containsChinese, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
import { isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
import { saveTextFile } from "@/lib/export/saveTextFile";
@@ -199,8 +199,11 @@ interface ChatMessage {
reasoning?: string;
isThinking?: boolean;
agentSteps?: AiAgentStepItem[];
/** Hidden system-generated context summary; not rendered in chat UI but included in LLM history. */
kind?: "contextSummary";
/** Hidden system-generated context summary; not rendered in chat UI but included in LLM history.
* `writeSqlConfirmation` / `productionWriteBlocked` mark backend-generated,
* localized confirmation/block messages so the UI does not re-detect them
* from text phrasing. */
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
/** Per-message token stats from the last agent run; ephemeral, not persisted. */
tokens?: { input: number; output: number };
}
@@ -906,7 +909,15 @@ const proposalConfirmMessage = computed<ChatMessage | null>(() => {
if (msg.kind === "contextSummary") continue;
if (msg.role !== "assistant") return null;
if (!msg.content) return null;
return looksLikeActionProposal(msg.content) ? msg : null;
// Backend-generated write confirmations are localized, so the English/Chinese
// phrase detectors cannot recognize them; the `kind` marker plus one exact
// SQL block is the structural proof of actionability.
if (msg.kind === "writeSqlConfirmation") return extractSingleSqlCodeBlock(msg.content) ? msg : null;
if (!looksLikeActionProposal(msg.content)) return null;
// A generic write question cannot authorize a later, unseen tool call.
// Hide its action bar until the assistant displays one exact SQL block.
if (looksLikeWriteSqlProposal(msg.content) && !isActionableWriteSqlProposal(msg.content)) return null;
return msg;
}
return null;
});
@@ -940,17 +951,17 @@ function sendProposalReply(positive: boolean) {
if (isGenerating.value) return;
const target = proposalConfirmMessage.value;
if (!target) return;
if (positive && productionContext.value.active && looksLikeWriteSqlProposal(target.content)) {
const isWriteConfirmation = isActionableWriteProposalMessage(target);
if (positive && productionContext.value.active && (target.kind === "writeSqlConfirmation" || looksLikeWriteSqlProposal(target.content))) {
const sql = extractFirstSqlCodeBlock(target.content);
if (sql) emit("replaceSql", sql);
toast(t("production.aiReviewRequired"), 5000);
return;
}
const isZh = containsChinese(target.content || "");
const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。";
const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation.";
prompt.value = isZh ? replyZh : replyEn;
if (positive && assistantMode.value === "agent" && looksLikeWriteSqlProposal(target.content)) {
// Write confirmations carry the exact-SQL reply; other action proposals keep
// the generic wording so the model does not receive SQL-specific instructions.
prompt.value = positive ? (isWriteConfirmation ? t("ai.writeSqlConfirmationReplyYes") : t("ai.proposalConfirmReplyYes")) : isWriteConfirmation ? t("ai.writeSqlConfirmationReplyNo") : t("ai.proposalConfirmReplyNo");
if (positive && assistantMode.value === "agent" && isWriteConfirmation) {
confirmedWriteSqlText = extractSingleSqlCodeBlock(target.content);
if (confirmedWriteSqlText) {
allowWriteSqlForNextRun = true;
@@ -1141,6 +1152,31 @@ function appendAssistantDelta(assistantIdx: number, delta: string) {
scheduleAssistantDeltaFlush(assistantIdx);
}
function replaceAssistantText(assistantIdx: number, content: string) {
// A model can stream prose or a code block before returning a write tool call.
// Discard that partial output so the confirmation detector sees exactly one SQL block.
if (assistantDeltaFrame !== null) {
cancelAnimationFrame(assistantDeltaFrame);
assistantDeltaFrame = null;
}
pendingAssistantDelta = "";
pendingAssistantReasoning = "";
pendingAssistantIndex = -1;
const msg = messages.value[assistantIdx];
if (!msg) return;
msg.content = content;
msg.reasoning = undefined;
msg.isThinking = false;
}
function writeSqlConfirmationText(sql: string): string {
return `${t("ai.writeSqlConfirmationRequired")}\n\n\`\`\`sql\n${sql.trim()}\n\`\`\`\n\n${t("ai.writeSqlConfirmationQuestion")}`;
}
function productionWriteBlockedText(sql: string): string {
return `${t("ai.productionWriteBlocked")}\n\n\`\`\`sql\n${sql.trim()}\n\`\`\``;
}
function appendAssistantReasoning(assistantIdx: number, delta: string) {
pendingAssistantReasoning += delta;
scheduleAssistantDeltaFlush(assistantIdx);
@@ -2449,6 +2485,16 @@ async function send() {
if (event.type === "text_delta" && event.delta) {
appendAssistantDelta(assistantIdx, event.delta);
}
if (event.type === "write_sql_confirmation_required") {
replaceAssistantText(assistantIdx, writeSqlConfirmationText(event.sql));
const msg = messages.value[assistantIdx];
if (msg) msg.kind = "writeSqlConfirmation";
}
if (event.type === "production_write_blocked") {
replaceAssistantText(assistantIdx, productionWriteBlockedText(event.sql));
const msg = messages.value[assistantIdx];
if (msg) msg.kind = "productionWriteBlocked";
}
if (event.type === "reasoning_delta" && event.delta) {
appendAssistantReasoning(assistantIdx, event.delta);
}
@@ -3293,11 +3339,11 @@ async function openExternalUrl(url: string) {
<div v-if="msg === proposalConfirmMessage" class="mt-2 flex gap-2" :title="t('ai.proposalConfirmTitle')">
<Button size="sm" variant="default" class="h-7 gap-1 text-[11px]" @click="sendProposalReply(true)">
<Check class="h-3 w-3" />
{{ t("ai.proposalConfirmYes") }}
{{ t(isActionableWriteProposalMessage(msg) ? "ai.writeSqlConfirmYes" : "ai.proposalConfirmYes") }}
</Button>
<Button size="sm" variant="outline" class="h-7 gap-1 text-[11px]" @click="sendProposalReply(false)">
<X class="h-3 w-3" />
{{ t("ai.proposalConfirmNo") }}
{{ t(isActionableWriteProposalMessage(msg) ? "ai.writeSqlConfirmNo" : "ai.proposalConfirmNo") }}
</Button>
</div>
</div>
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
const localePaths = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"];
describe("AI assistant write confirmation replacement", () => {
it("renders a localized confirmation from the backend's semantic event", () => {
expect(source).toContain("function replaceAssistantText(assistantIdx: number, content: string)");
expect(source).toContain('pendingAssistantDelta = "";');
expect(source).toContain("msg.content = content;");
expect(source).toContain("function writeSqlConfirmationText(sql: string)");
expect(source).toContain('event.type === "write_sql_confirmation_required"');
expect(source).toContain("replaceAssistantText(assistantIdx, writeSqlConfirmationText(event.sql));");
expect(source).toContain('event.type === "production_write_blocked"');
});
it("does not render an executable write confirmation without exact SQL", () => {
expect(source).toContain("looksLikeWriteSqlProposal(msg.content) && !isActionableWriteSqlProposal(msg.content)");
expect(source).toContain('if (positive && assistantMode.value === "agent" && isWriteConfirmation)');
});
it("routes generic action replies separately from write-SQL replies", () => {
expect(source).toContain('isWriteConfirmation ? t("ai.writeSqlConfirmationReplyYes") : t("ai.proposalConfirmReplyYes")');
expect(source).toContain('isWriteConfirmation ? t("ai.writeSqlConfirmationReplyNo") : t("ai.proposalConfirmReplyNo")');
});
it("marks backend-generated confirmations as structurally actionable", () => {
expect(source).toContain('msg.kind = "writeSqlConfirmation";');
expect(source).toContain('msg.kind = "productionWriteBlocked";');
expect(source).toContain('if (msg.kind === "writeSqlConfirmation") return extractSingleSqlCodeBlock(msg.content) ? msg : null;');
expect(source).toContain("isActionableWriteProposalMessage(msg)");
});
it("provides the write-confirmation wording in every supported locale", () => {
for (const locale of localePaths) {
const messages = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
expect(messages).toContain("writeSqlConfirmationRequired:");
expect(messages).toContain("writeSqlConfirmationQuestion:");
expect(messages).toContain("writeSqlConfirmYes:");
expect(messages).toContain("writeSqlConfirmNo:");
expect(messages).toContain("productionWriteBlocked:");
expect(messages).toContain("proposalConfirmReplyYes:");
expect(messages).toContain("proposalConfirmReplyNo:");
}
});
});
+9
View File
@@ -2487,6 +2487,15 @@ export default {
truncated: "Context truncated",
contextSummary: "{database} · {tables} tables",
autoSqlBlocked: "The AI-generated SQL looked too risky to auto-execute. Review it manually before running.",
writeSqlConfirmationRequired: "This SQL changes data or schema. Review it carefully before execution.",
writeSqlConfirmationQuestion: "Execute this SQL once?",
writeSqlConfirmYes: "Confirm execution",
writeSqlConfirmNo: "Cancel",
writeSqlConfirmationReplyYes: "Execute the exact SQL shown above once. Do not ask for confirmation again.",
writeSqlConfirmationReplyNo: "Do not execute the SQL shown above. Continue the conversation.",
productionWriteBlocked: "AI cannot execute data or schema changes on a production database. Review this SQL and run it manually in DBX.",
proposalConfirmReplyYes: "Execute the action you just proposed above; do not ask for confirmation again.",
proposalConfirmReplyNo: "Do not execute the action mentioned above; continue the current conversation.",
proposalConfirmYes: "Yes, proceed",
proposalConfirmNo: "No thanks",
proposalConfirmTitle: "Action confirmation required",
+9
View File
@@ -2526,6 +2526,15 @@ export default withEnglishFallback({
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tablas",
autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.",
writeSqlConfirmationRequired: "Este SQL modifica datos o el esquema. Revísalo cuidadosamente antes de ejecutarlo.",
writeSqlConfirmationQuestion: "¿Ejecutar este SQL una vez?",
writeSqlConfirmYes: "Confirmar ejecución",
writeSqlConfirmNo: "Cancelar",
writeSqlConfirmationReplyYes: "Ejecuta una vez el SQL exacto mostrado arriba. No vuelvas a pedir confirmación.",
writeSqlConfirmationReplyNo: "No ejecutes el SQL mostrado arriba. Continúa la conversación.",
productionWriteBlocked: "La IA no puede ejecutar cambios de datos o esquema en una base de datos de producción. Revisa este SQL y ejecútalo manualmente en DBX.",
proposalConfirmReplyYes: "Ejecuta la acción que acabas de proponer arriba; no vuelvas a pedir confirmación.",
proposalConfirmReplyNo: "No ejecutes la acción mencionada arriba; continúa la conversación.",
proposalConfirmYes: "Sí, continuar",
proposalConfirmNo: "No, gracias",
proposalConfirmTitle: "Confirmación de acción requerida",
+9
View File
@@ -2420,6 +2420,15 @@ export default withEnglishFallback({
truncated: "Contesto troncato",
contextSummary: "{database} · {tables} tabelle",
autoSqlBlocked: "L'SQL generato dall'AI è sembrato troppo rischioso per l'esecuzione automatica. Rivedilo manualmente prima di eseguirlo.",
writeSqlConfirmationRequired: "Questo SQL modifica dati o schema. Rivedilo attentamente prima dell'esecuzione.",
writeSqlConfirmationQuestion: "Eseguire questo SQL una volta?",
writeSqlConfirmYes: "Conferma esecuzione",
writeSqlConfirmNo: "Annulla",
writeSqlConfirmationReplyYes: "Esegui una sola volta l'SQL esatto mostrato sopra. Non chiedere di nuovo conferma.",
writeSqlConfirmationReplyNo: "Non eseguire l'SQL mostrato sopra. Continua la conversazione.",
productionWriteBlocked: "L'AI non può eseguire modifiche a dati o schema su un database di produzione. Rivedi questo SQL ed eseguilo manualmente in DBX.",
proposalConfirmReplyYes: "Esegui l'azione che hai appena proposto sopra; non chiedere di nuovo conferma.",
proposalConfirmReplyNo: "Non eseguire l'azione menzionata sopra; continua la conversazione.",
proposalConfirmYes: "Sì, procedi",
proposalConfirmNo: "No, grazie",
proposalConfirmTitle: "Conferma azione richiesta",
+9
View File
@@ -2560,6 +2560,15 @@ export default withEnglishFallback({
truncated: "コンテキストが切り詰められました",
contextSummary: "{database} · {tables}テーブル",
autoSqlBlocked: "AIが生成したSQLは自動実行するにはリスクが高すぎると判断されました。実行前に手動で確認してください。",
writeSqlConfirmationRequired: "この SQL はデータまたはスキーマを変更します。実行前に内容をよく確認してください。",
writeSqlConfirmationQuestion: "この SQL を一度実行しますか?",
writeSqlConfirmYes: "実行を確認",
writeSqlConfirmNo: "キャンセル",
writeSqlConfirmationReplyYes: "上に表示された正確な SQL を一度だけ実行してください。確認を再度求めないでください。",
writeSqlConfirmationReplyNo: "上に表示された SQL を実行しないでください。会話を続けてください。",
productionWriteBlocked: "AI は本番データベースでデータまたはスキーマの変更を実行できません。この SQL を確認し、DBX で手動実行してください。",
proposalConfirmReplyYes: "上で提案した操作を実行してください。再度確認を求めないでください。",
proposalConfirmReplyNo: "上で述べた操作は実行しないでください。会話を続けてください。",
proposalConfirmYes: "はい、続行",
proposalConfirmNo: "いいえ",
proposalConfirmTitle: "操作の確認",
+9
View File
@@ -2337,6 +2337,15 @@ export default withEnglishFallback({
truncated: "컨텍스트가 잘렸습니다",
contextSummary: "{database} · 테이블 {tables}개",
autoSqlBlocked: "AI가 생성한 SQL이 자동 실행하기에는 너무 위험해 보입니다. 실행 전에 수동으로 검토하세요.",
writeSqlConfirmationRequired: "이 SQL은 데이터 또는 스키마를 변경합니다. 실행 전에 신중하게 검토하세요.",
writeSqlConfirmationQuestion: "이 SQL을 한 번 실행할까요?",
writeSqlConfirmYes: "실행 확인",
writeSqlConfirmNo: "취소",
writeSqlConfirmationReplyYes: "위에 표시된 정확한 SQL을 한 번만 실행하세요. 다시 확인하지 마세요.",
writeSqlConfirmationReplyNo: "위에 표시된 SQL을 실행하지 말고 대화를 계속하세요.",
productionWriteBlocked: "AI는 운영 데이터베이스에서 데이터 또는 스키마 변경을 실행할 수 없습니다. 이 SQL을 검토한 후 DBX에서 수동으로 실행하세요.",
proposalConfirmReplyYes: "위에서 제안한 작업을 실행하세요. 다시 확인을 요청하지 마세요.",
proposalConfirmReplyNo: "위에서 언급한 작업을 실행하지 말고 대화를 계속하세요.",
proposalConfirmYes: "예, 진행",
proposalConfirmNo: "아니오",
proposalConfirmTitle: "작업 확인이 필요합니다",
+9
View File
@@ -2528,6 +2528,15 @@ export default withEnglishFallback({
truncated: "Contexto truncado",
contextSummary: "{database} · {tables} tabelas",
autoSqlBlocked: "O SQL gerado pela AI pareceu arriscado demais para execução automática. Revise-o manualmente antes de executar.",
writeSqlConfirmationRequired: "Este SQL altera dados ou o esquema. Revise-o cuidadosamente antes de executar.",
writeSqlConfirmationQuestion: "Executar este SQL uma vez?",
writeSqlConfirmYes: "Confirmar execução",
writeSqlConfirmNo: "Cancelar",
writeSqlConfirmationReplyYes: "Execute uma vez o SQL exato mostrado acima. Não peça confirmação novamente.",
writeSqlConfirmationReplyNo: "Não execute o SQL mostrado acima. Continue a conversa.",
productionWriteBlocked: "A IA não pode executar alterações de dados ou esquema em um banco de dados de produção. Revise este SQL e execute-o manualmente no DBX.",
proposalConfirmReplyYes: "Execute a ação que você acabou de propor acima; não peça confirmação novamente.",
proposalConfirmReplyNo: "Não execute a ação mencionada acima; continue a conversa.",
proposalConfirmYes: "Sim, continuar",
proposalConfirmNo: "Não, obrigado",
proposalConfirmTitle: "Confirmação de ação necessária",
+9
View File
@@ -2411,6 +2411,15 @@ export default withEnglishFallback({
truncated: "上下文已截断",
contextSummary: "{database} · {tables} 张表",
autoSqlBlocked: "AI 生成的 SQL 风险较高,已阻止自动执行,请手动检查后再运行。",
writeSqlConfirmationRequired: "此 SQL 会修改数据或结构。执行前请仔细核对。",
writeSqlConfirmationQuestion: "确认执行此 SQL 一次吗?",
writeSqlConfirmYes: "确认执行",
writeSqlConfirmNo: "取消",
writeSqlConfirmationReplyYes: "仅执行上方展示的精确 SQL 一次,不要再次询问确认。",
writeSqlConfirmationReplyNo: "不要执行上方展示的 SQL,继续当前对话。",
productionWriteBlocked: "AI 不能在生产数据库执行数据或结构变更。请核对此 SQL 后在 DBX 中手动执行。",
proposalConfirmReplyYes: "请执行上面你刚提议的操作,不要再反问确认。",
proposalConfirmReplyNo: "不用执行上面提到的操作,继续当前对话。",
proposalConfirmYes: "是,请继续",
proposalConfirmNo: "不用了",
proposalConfirmTitle: "需要确认是否执行以下操作",
+9
View File
@@ -2421,6 +2421,15 @@ export default withEnglishFallback({
truncated: "上下文已截斷",
contextSummary: "{database} · {tables} 張資料表",
autoSqlBlocked: "AI 產生的 SQL 風險較高,已阻止自動執行,請手動檢查後再執行。",
writeSqlConfirmationRequired: "此 SQL 會修改資料或結構。執行前請仔細核對。",
writeSqlConfirmationQuestion: "確認執行此 SQL 一次嗎?",
writeSqlConfirmYes: "確認執行",
writeSqlConfirmNo: "取消",
writeSqlConfirmationReplyYes: "僅執行上方顯示的精確 SQL 一次,不要再次詢問確認。",
writeSqlConfirmationReplyNo: "不要執行上方顯示的 SQL,繼續目前對話。",
productionWriteBlocked: "AI 無法在生產資料庫執行資料或結構變更。請核對此 SQL 後在 DBX 中手動執行。",
proposalConfirmReplyYes: "請執行上方你剛剛提議的操作,不要再反問確認。",
proposalConfirmReplyNo: "不要執行上方提到的操作,繼續目前對話。",
proposalConfirmYes: "是,請繼續",
proposalConfirmNo: "不用了",
proposalConfirmTitle: "需要確認是否執行以下操作",
@@ -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(
+33 -2
View File
@@ -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;
+3 -1
View File
@@ -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 {