From 405c708e1742c3a0c63f23ff081bece992b4050d Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 2 Oct 2026 01:53:15 +0300 Subject: [PATCH] fix(prompts): a summary is history, not a live instruction Closes #560 ## Problem Compaction can preserve an earlier user approval as if it were the current instruction. The reported case: a question tool asked permission to write a spec, the user answered positively, and after a compaction the summary still carried that approval. A later turn read it, concluded the user had authorised writing the spec, and a **PLAN-mode agent started modifying files** even though the user had since said not to execute. The mode restriction is enforced by the host, not by the model, so a model that believes it was told "go ahead" will act on that belief. There is no second line of defence in front of it. The prompts had `USER INTENT FIDELITY`, which asks the summariser to preserve intent faithfully. That is the wrong instruction for an approval: preserving it faithfully and preserving it *as an active instruction* are different things, and nothing told the model which one to do. ## Change A `TEMPORAL STATUS OF USER MESSAGES` section in both compress prompts (`compress-range` and `compress-message`, in the xml and compact variants): - a summary is history; everything inside it has already happened - approvals, permissions and confirmations must be recorded as a past event, never as a live instruction - write them in past tense with their position in the timeline, and say that the user has not restated the approval since - **never place an approval at the end of a summary**, where it reads as the most recent thing the user said, and never end a summary on an imperative sentence taken from a user message - when a later instruction narrows, overrides or cancels an approval, state both and make the later one operative - an unresolved question stays described as unresolved The two prompts differ in scope: range mode compresses a span and therefore carries the placement rules, while message mode compresses individual messages and only carries the past-tense and precedence rules. ## What this does not do It does not enforce anything. A summary is produced by a model, and a model can still get it wrong. What changes is that the prompt no longer invites the failure and the common cases - an approval at the tail of a summary, a cancelled approval still reading as live - are spelled out. The durable fix is host-side, where mode restrictions are actually enforced, so this is the part a plugin can carry. ## Verification `tests/prompt-approval-status.test.ts`, six cases, all four prompt variants: - every prompt states a summary is history - every prompt forbids replaying an approval as a live instruction - range mode forbids ending a summary on an approval - range mode requires the later instruction to win - unresolved decisions stay unresolved - the existing id and placeholder contracts the tool schema depends on are asserted unchanged All six fail on the current implementation. Full suite 132 passing, `tsc --noEmit` clean, `tsup` builds, `prettier --check` clean. --- lib/prompts/compress-message.ts | 8 +++ lib/prompts/compress-range.ts | 11 ++++ tests/prompt-approval-status.test.ts | 76 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/prompt-approval-status.test.ts diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts index 030fc0c7..62c0d98f 100644 --- a/lib/prompts/compress-message.ts +++ b/lib/prompts/compress-message.ts @@ -12,6 +12,14 @@ USER INTENT FIDELITY When a selected message contains user intent, preserve that intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. Directly quote short user instructions when that best preserves exact meaning. +TEMPORAL STATUS OF USER MESSAGES +A summary is HISTORY, not the current instruction. + +Approvals, permissions and confirmations are the failure mode here. Text like "go ahead and do it", "yes, do that", or a positive answer to a question tool must be recorded as a past event, never as a live instruction. Otherwise a later turn reads the summary, sees an approval and acts on it - including a PLAN-mode agent starting to modify files after the user has since told it to stop. + +- Write it as past tense: "the user approved writing the spec at that point", not "the user approved writing the spec". +- If a later message in the same batch narrows, overrides or cancels it, make the later one operative and say so. + Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool output, and repetition. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. If a message contains no significant technical decisions, code changes, or user requirements, produce a minimal one-line summary rather than a detailed one. diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index 4835bd6d..4057fe31 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -14,6 +14,17 @@ USER INTENT FIDELITY When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning. +TEMPORAL STATUS OF USER MESSAGES +A compressed range is HISTORY, not the current instruction. A summary sits behind a [Compressed conversation section] header, and anything inside it has already happened. + +Approvals, permissions and confirmations are the failure mode here. Text like "go ahead and do it", "yes, do that", "start writing the spec", or a positive answer to a question tool must be recorded as a past event with its position in the timeline, never as a live instruction. Otherwise a later turn can read the summary, see an approval, and act on it - including a PLAN-mode agent starting to modify files after the user has since told it to stop. + +- Write it as past tense with its position: "At that point the user approved writing the spec (third turn of this range). The user has not restated this approval since." +- Never place an approval at the end of the summary, where it reads as the most recent thing the user said. +- Never end a summary with an imperative sentence taken from a user message. +- If the range contains both an approval and a later instruction that narrows, overrides or cancels it, state both and make the later one the operative one. +- If the range ends with a question or an unresolved decision, say that it is unresolved. + Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. COMPRESSED BLOCK PLACEHOLDERS diff --git a/tests/prompt-approval-status.test.ts b/tests/prompt-approval-status.test.ts new file mode 100644 index 00000000..7db844fb --- /dev/null +++ b/tests/prompt-approval-status.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const root = mkdtempSync(join(tmpdir(), "dcp-prompt-approval-")) +process.env.XDG_DATA_HOME = join(root, "data") +process.env.XDG_CONFIG_HOME = join(root, "config") +process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode") +test.after(() => rmSync(root, { recursive: true, force: true })) + +const { rangePrompt } = await import("../lib/prompts/compress-range") +const { messagePrompt } = await import("../lib/prompts/compress-message") + +const PROMPTS = [ + ["range", rangePrompt("xml")], + ["range compact", rangePrompt("compact")], + ["message", messagePrompt("xml")], + ["message compact", messagePrompt("compact")], +] as const + +// A compressed range is history. If the summary ends with a user approval as a +// bare imperative, a later turn - notably a PLAN-mode agent - can read it as the +// current instruction and start modifying files (#560). +test("every compress prompt states that a summary is history", () => { + for (const [name, prompt] of PROMPTS) { + assert.match(prompt, /TEMPORAL STATUS OF USER MESSAGES/, name) + assert.match(prompt, /HISTORY, not the current instruction/, name) + } +}) + +test("every compress prompt forbids replaying an approval as a live instruction", () => { + for (const [name, prompt] of PROMPTS) { + assert.match(prompt, /approvals?/i, name) + assert.match(prompt, /recorded as a past event[^.]*never as a live instruction/i, name) + assert.match(prompt, /PLAN-mode agent/i, name) + } +}) + +test("the range prompt forbids ending a summary on an approval", () => { + for (const [name, prompt] of [PROMPTS[0], PROMPTS[1]]) { + assert.match(prompt, /Never end a summary with an imperative sentence/i, name) + assert.match(prompt, /Never place an approval at the end of the summary/i, name) + } +}) + +test("the range prompt requires the later instruction to win", () => { + for (const [name, prompt] of [PROMPTS[0], PROMPTS[1]]) { + assert.match(prompt, /narrows, overrides or cancels it/i, name) + assert.match(prompt, /make the later one the operative one/i, name) + } +}) + +test("an unresolved decision must stay unresolved", () => { + for (const [name, prompt] of [PROMPTS[0], PROMPTS[1]]) { + assert.match(prompt, /unresolved/i, name) + } +}) + +test("the existing prompt contracts are untouched", () => { + // Range mode keeps the block placeholder contract that the tool schema and + // the id format depend on. + for (const prompt of [rangePrompt("xml"), rangePrompt("compact")]) { + assert.match(prompt, /Include every required block placeholder exactly once/) + assert.match(prompt, /startId/) + } + // Message mode keeps the id contract. + for (const prompt of [messagePrompt("xml"), messagePrompt("compact")]) { + assert.match(prompt, /messageId/) + } + assert.match(rangePrompt("compact"), /@b1@/) + assert.match(rangePrompt("xml"), /