Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/prompts/compress-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions lib/prompts/compress-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions tests/prompt-approval-status.test.ts
Original file line number Diff line number Diff line change
@@ -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"), /<dcp-message-id/)
assert.match(messagePrompt("compact"), /@7@ \[high\]/)
assert.match(messagePrompt("xml"), /m0007/)
})