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
@@ -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>
|
||||
|
||||
+47
@@ -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:");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "操作の確認",
|
||||
|
||||
@@ -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: "작업 확인이 필요합니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "需要确认是否执行以下操作",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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