diff --git a/src/compress-loop.ts b/src/compress-loop.ts new file mode 100644 index 0000000..2ea998e --- /dev/null +++ b/src/compress-loop.ts @@ -0,0 +1,16 @@ +// Independent stop-signal injected as a user-role message once a compress loop +// is confirmed within the current user turn (COMPRESS_LOOP_CORRECT_THRESHOLD+ +// failed/no-op compress calls without progress). It is appended per context event +// and NOT persisted to the session log, so it self-clears when the turn changes. +// The #308/#6/#250 breakers stop the TOOL from doing damage but cannot stop the +// MODEL from generating another ~10K-token repetitive compress turn; only an +// input-side counter-signal breaks the semantic attractor (issue #330). Follows +// the provider-throttle sentinel pattern (throttle-retry.ts) so system-prompt.ts +// documents how to interpret it. +export const COMPRESS_LOOP_SENTINEL = "[ACP:compress-loop]"; + +export const COMPRESS_LOOP_CORRECT_THRESHOLD = 2; + +export function buildCompressLoopText(failures: number): string { + return `${COMPRESS_LOOP_SENTINEL} You have issued ${failures} compress calls this turn without making progress (identical or already-compressed ranges). STOP calling compress now. Continue your actual task using the context you already have — compression is paused until your next user request.`; +} diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 25672b0..67967d6 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -296,7 +296,7 @@ function cappedRejectionText(snapshot: string): string { "Current compressible ranges (use these refs exactly as listed):", snapshot, "", - "Continue the task; compress becomes available again on the next user message.", + "Continue the task WITHOUT compressing. If none of the ranges above fit, call acp_status for the full picture. Compress becomes available again on the next user message.", ].join("\n"); } diff --git a/src/index.ts b/src/index.ts index ed36956..a4649a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import { throttleDelayMs, } from "./throttle-retry.js"; import { defaultCountTokens } from "acp-kernel"; +import { COMPRESS_LOOP_CORRECT_THRESHOLD, buildCompressLoopText } from "./compress-loop.js"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; import { applyOutputHeadroom, inspectOverflowMessage, resolveOutputHeadroomCap } from "./overflow-selfheal.js"; import { UNSUPPORTED_HOST_MESSAGE } from "./omp.js"; @@ -497,6 +498,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // must lift the cap on this same fire). const compressOutcomes = collectCompressOutcomes(entries, lastTurnBoundaryIndex(entries, turnPolicy)); const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(sid, turnKey, compressOutcomes) : null; + // Failed/no-op compress attempts counted so far THIS user turn (0 when the + // key doesn't match the tracked turn; sid-scoped per #327). Drives both nudge + // suppression (#330: engages on the FIRST failure) and the independent + // stop-signal below. Read AFTER noteCompressOutcomes above so it reflects the + // newest outcome on this same fire. + const compressFails = runtime.compressFailCountFor(sid, turnKey); // Growth-aware re-inject bookkeeping (issue #269) runs on EVERY context // event, not only when the kernel wants to inject: the drop re-anchor @@ -560,9 +567,14 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // keeps usage pinned at emergency). Once this turn burned // MAX_COMPRESS_ATTEMPTS attempts, stop re-injecting the nudge — the // kernel's emergency truncation still shrinks context mechanically. - const retryCapped = runtime.compressRetryCappedFor(sid, turnKey); + // Nudge suppression engages on the FIRST failed/no-op compress attempt this + // turn, not only at the MAX_COMPRESS_ATTEMPTS cap: once the model has chased a + // failing compression, re-pushing "compress more" reinforces the loop instead of + // helping — the failure toolResult already carries actionable refs (#330). + // Subsumes the retry-capped gate (failures >= MAX implies >= 1) while keeping + // its lift-on-success semantics. const reInjectReady = shownAt === undefined || tokenCount - shownAt >= reInjectFloor; - const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(sid, turnKey) && !reInjectReady); + const alreadyShown = compressFails >= 1 || (!emergency && runtime.nudgeShownFor(sid, turnKey) && !reInjectReady); if (!alreadyShown) { rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts, activeNudgeSections(runtime, ctx))); const rendered = renderNudgeText(turn.nudge, runtime.prompts, activeNudgeSections(runtime, ctx)); @@ -589,6 +601,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf } } + if (compressFails >= COMPRESS_LOOP_CORRECT_THRESHOLD) { + rebuilt.push({ role: "user", content: [{ type: "text", text: buildCompressLoopText(compressFails) }], timestamp: Date.now() } as AgentMessage); + logWarn("nudge", { sid, event: "compress-loop-correction", failures: compressFails }); + debug.event("compress-loop-correction", { sid, turnKey, failures: compressFails }); + } + // Always return the transformed array: every message needs its [mNNNNN] ref // tag applied, so there is no meaningful "no change" case to short-circuit. debug.event("context-out", { outMsgs: rebuilt.length, injected: turn.nudge?.shouldInject ?? false, emergency: turn.nudge?.breakdown?.emergencyOverride === 1 }); diff --git a/src/messages.ts b/src/messages.ts index 19ef44c..2b7ad9e 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -13,6 +13,7 @@ type AnyMessage = { command?: string; output?: unknown; summary?: string; + stopReason?: string; }; const REF_TAG_SOURCE = "(?:\x3cacp\\s[^>]*\x3em\\d{5}\x3c/acp\x3e|\\[m\\d{1,5}\\])"; @@ -49,6 +50,19 @@ export function isCustomMessageEntry(entry: TurnBoundaryEntry): entry is TurnBou return extractText(entry.content).length > 0; } +// An assistant turn aborted or errored by the user never ran its tools, so its +// tool_calls blocks have no matching tool_result. Sending them is an invalid +// sequence for OpenAI-compatible providers (openai-completions 400s on +// tool_calls with no following tool message) and is the hook that drags the model +// back into re-issuing the abandoned call (issue #330). Keying off stopReason — +// not a "missing toolResult" scan — is deliberate: OMP execution roles and +// evicted/undo fixtures carry no stopReason, so a result-presence scan would +// false-positive on their (legitimately paired) tool calls. +const INTERRUPTED_STOP_REASONS = new Set(["aborted", "error"]); +function wasInterrupted(msg: AnyMessage): boolean { + return typeof msg.stopReason === "string" && INTERRUPTED_STOP_REASONS.has(msg.stopReason); +} + export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { const out: CoreMessage[] = []; for (const entry of entries) { @@ -91,7 +105,12 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] { // exactly one core per turn — the first emitted one. const thinking = thinkingTokenCount(msg.content); const thinkingField = thinking > 0 ? { thinkingTokens: thinking } : {}; - const calls = allToolCalls(msg.content); + let calls = allToolCalls(msg.content); + // Interrupted turn → its tools never ran → drop the unmatched tool_calls so + // the sent view carries no dangling tool_use (see wasInterrupted). If the + // dropped call was the only content, this falls through to the text path + // below, which drops the turn too when there is no visible text. + if (wasInterrupted(msg)) calls = []; if (calls.length > 0) { const textParts = extractText(msg.content); if (calls.length === 1) { diff --git a/src/runtime.ts b/src/runtime.ts index d19790f..f600730 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -99,6 +99,11 @@ export interface AcpRuntime { * compress calls — used to stop re-injecting the (dedup-exempt) emergency * nudge that would otherwise keep looping no-op compressions (issue #6). */ compressRetryCappedFor(sid: string, turnKey: string): boolean; + /** Failed/no-op compress calls counted for this turn, or 0 when turnKey does + * not match the currently tracked turn (sid-scoped per #327). Distinguishes + * "no failure yet" from "hard-capped" so nudge suppression can engage on the + * first failed attempt rather than only at MAX_COMPRESS_ATTEMPTS (issue #330). */ + compressFailCountFor(sid: string, turnKey: string): number; clearNudgeTracking(sid: string): void; clearCompressRetryTracking(sid: string): void; liveContextLimit(ctx: ExtensionContext): number; @@ -416,6 +421,11 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { return t !== undefined && t.failTurnKey === turnKey && t.failCount >= MAX_COMPRESS_ATTEMPTS; } + function compressFailCountFor(sid: string, turnKey: string): number { + const t = compressTrackerFor(sid); + return t.failTurnKey === turnKey ? t.failCount : 0; + } + function clearCompressRetryTracking(sid: string): void { compressOutcomes.delete(sid); } @@ -564,4 +574,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, deriveChildState: deriveChild, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, compressFailCountFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, deriveChildState: deriveChild, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/src/system-prompt.ts b/src/system-prompt.ts index b26532f..016475d 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -11,6 +11,7 @@ export interface PiPromptSections { decompressPhilosophy?: SectionOverride; contextBreakdown?: SectionOverride; throttleRetry?: SectionOverride; + compressLoopGuard?: SectionOverride; philosophy?: null; howToCompress?: null; tier2?: null; @@ -70,11 +71,15 @@ When context usage passes a threshold, the system appends a breakdown showing wh A provider rate-limit error (e.g. "Too many tokens, please wait before trying again.") may appear as a failed assistant response followed by a [ACP:provider-throttle] note. The interruption was transient and the system is retrying automatically. After such an interruption, resume the interrupted step exactly where it left off: do not re-run completed steps, do not re-read content already in context, and do not discuss the interruption unless asked. Retries are capped; when the cap is reached the error is surfaced to the user unchanged. If the user sends new input during a retry wait, the retry is cancelled.`], + ["compressLoopGuard", `COMPRESS LOOP GUARD + +If you see a note beginning with [ACP:compress-loop], you have been repeatedly issuing compress calls this turn without making progress (identical or already-compressed ranges). STOP calling compress immediately and do not try to "fix" it by re-issuing another compress call — that is exactly what is looping. Continue your actual task using the context you already have. Compressing becomes available again on the next user message.`], ]; const PROMPT_SECTION_KEYS: ReadonlySet = new Set([ "acpTags", "summariesInContext", "tools", "whenToCompress", "whenNotToCompress", "multiTierIntro", "decompressPhilosophy", "contextBreakdown", "throttleRetry", + "compressLoopGuard", ]); const RULE_SLOT_KEYS: ReadonlySet = new Set(["philosophy", "howToCompress", "tier2", "tier3"]); diff --git a/tests/compress-loop.test.ts b/tests/compress-loop.test.ts new file mode 100644 index 0000000..720e5e2 --- /dev/null +++ b/tests/compress-loop.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { COMPRESS_LOOP_SENTINEL, COMPRESS_LOOP_CORRECT_THRESHOLD, buildCompressLoopText } from "../src/compress-loop.js"; + +test("COMPRESS_LOOP_SENTINEL matches the sentinel documented in the system prompt — issue #330", () => { + assert.equal(COMPRESS_LOOP_SENTINEL, "[ACP:compress-loop]"); +}); + +test("correction threshold fires on 2 failed compress calls within one turn — issue #330", () => { + assert.equal(COMPRESS_LOOP_CORRECT_THRESHOLD, 2); +}); + +test("buildCompressLoopText carries the sentinel, the count, and an explicit stop instruction", () => { + const text = buildCompressLoopText(2); + assert.ok(text.startsWith(COMPRESS_LOOP_SENTINEL), "sentinel first so system-prompt rules can key off it"); + assert.ok(text.includes("2"), "embeds the failure count"); + assert.match(text, /STOP calling compress/, "explicit stop instruction"); + assert.match(text, /paused until your next user request/, "states when it self-clears"); +}); diff --git a/tests/compress-retry.test.ts b/tests/compress-retry.test.ts index 928a51b..755ba28 100644 --- a/tests/compress-retry.test.ts +++ b/tests/compress-retry.test.ts @@ -372,3 +372,71 @@ test("nudge ledger: per-session mark/query/clear isolation (#317)", () => { assert.equal(rt.nudgeShownFor("A", "u2"), true); assert.equal(rt.nudgeShownFor("B", "u2"), false); }); + +// ─── issue #330: semantic-level loop breaker ──────────────────────────────── +// +// The #308/#6/#250 breakers stop the TOOL from doing damage but cannot stop the +// MODEL from generating another ~10K-token repetitive compress turn under a +// low-temp attractor. Two input-side additions close that gap: +// 1. nudge suppression starts at the FIRST failure (not the MAX_COMPRESS_ATTEMPTS +// cap) — re-pushing "compress more" reinforces the loop; the failure +// toolResult already carries actionable refs. +// 2. once in-turn failures reach COMPRESS_LOOP_CORRECT_THRESHOLD (2), an +// independent [ACP:compress-loop] user-role stop-signal is injected. +// Both are driven by runtime.compressFailCountFor(sid, turnKey). + +test("compressFailCountFor: reports in-turn failures, 0 for other turns, resets on success/new turn (#330)", () => { + const rt = createRuntime({}); + const fail = (id: string) => ({ toolCallId: id, isError: true, success: false }); + const noop = (id: string) => ({ toolCallId: id, isError: false, success: false, noop: true }); + const success = (id: string) => ({ toolCallId: id, isError: false, success: true }); + + assert.equal(rt.compressFailCountFor("S", "u1"), 0, "no outcomes yet → 0"); + rt.noteCompressOutcomes("S", "u1", [fail("t0")]); + assert.equal(rt.compressFailCountFor("S", "u1"), 1); + assert.equal(rt.compressFailCountFor("S", "other"), 0, "unrelated turn → 0"); + assert.equal(rt.compressFailCountFor("T", "u1"), 0, "unrelated session → 0 (sid-scoped per #317)"); + rt.noteCompressOutcomes("S", "u1", [fail("t0"), noop("n0")]); + assert.equal(rt.compressFailCountFor("S", "u1"), 2, "error + no-op both advance the loop counter"); + rt.noteCompressOutcomes("S", "u1", [fail("t0"), noop("n0"), success("s0")]); + assert.equal(rt.compressFailCountFor("S", "u1"), 0, "genuine success resets → suppression/correction clear"); + rt.noteCompressOutcomes("S", "u2", [fail("a")]); + assert.equal(rt.compressFailCountFor("S", "u2"), 1); + assert.equal(rt.compressFailCountFor("S", "u1"), 0, "a new turn's failure does not leak into an old turn"); +}); + +test("loop correction: [ACP:compress-loop] injected exactly once per event at ≥2 in-turn failures, self-clears on new turn (#330)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 200_000 })(api as any); + const stateFile = "/tmp/pai-acp-loop-correct.session.json"; + await rm(`${stateFile}.acp.json`, { force: true }); + + const loopMsgs = (r: any) => + (r?.messages ?? []).filter((m: any) => m.role === "user" && /\[ACP:compress-loop\]/.test(JSON.stringify(m.content))); + + let entries: any[] = [userMsg("e1", ZH)]; + const ctx = fakeCtx(() => entries, stateFile); + await fire(handlers, ctx); // assign refs + + // first failed compress → count 1 → below threshold → no correction yet + entries = [...entries, toolResultMsg("e2", "call_1", VALIDATION_ERR, true)]; + const r1 = await fire(handlers, ctx); + assert.equal(loopMsgs(r1).length, 0, "1 failure < threshold 2 → no correction"); + + // second failed compress (no-op panel) → count 2 → correction fires + entries = [...entries, toolResultMsg("e3", "call_2", NOOP_PANEL, false)]; + const r2 = await fire(handlers, ctx); + assert.equal(loopMsgs(r2).length, 1, "2 failures ≥ threshold 2 → one correction injected"); + assert.match(JSON.stringify(loopMsgs(r2)[0]!.content), /STOP calling compress/, "carries an explicit stop instruction"); + + // third failure still yields exactly ONE correction per event (not cumulative) + entries = [...entries, toolResultMsg("e4", "call_3", VALIDATION_ERR, true)]; + const r3 = await fire(handlers, ctx); + assert.equal(loopMsgs(r3).length, 1, "still exactly one correction per context event"); + + // new user turn → fresh budget → the stale correction does not linger + entries = [...entries, userMsg("e5", "now do something else")]; + const r4 = await fire(handlers, ctx); + assert.equal(loopMsgs(r4).length, 0, "new turn resets the loop counter → no stale correction"); + await rm(`${stateFile}.acp.json`, { force: true }); +}); diff --git a/tests/fixtures/pi-system-prompt-default.txt b/tests/fixtures/pi-system-prompt-default.txt index a4d73f5..94975ed 100644 --- a/tests/fixtures/pi-system-prompt-default.txt +++ b/tests/fixtures/pi-system-prompt-default.txt @@ -166,3 +166,7 @@ PROVIDER THROTTLE RETRY A provider rate-limit error (e.g. "Too many tokens, please wait before trying again.") may appear as a failed assistant response followed by a [ACP:provider-throttle] note. The interruption was transient and the system is retrying automatically. After such an interruption, resume the interrupted step exactly where it left off: do not re-run completed steps, do not re-read content already in context, and do not discuss the interruption unless asked. Retries are capped; when the cap is reached the error is surfaced to the user unchanged. If the user sends new input during a retry wait, the retry is cancelled. + +COMPRESS LOOP GUARD + +If you see a note beginning with [ACP:compress-loop], you have been repeatedly issuing compress calls this turn without making progress (identical or already-compressed ranges). STOP calling compress immediately and do not try to "fix" it by re-issuing another compress call — that is exactly what is looping. Continue your actual task using the context you already have. Compressing becomes available again on the next user message. diff --git a/tests/messages.test.ts b/tests/messages.test.ts index 9a0ff37..ca4f0cb 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -461,3 +461,74 @@ test("message identity ignores tag-only text blocks but preserves original empty assert.equal(messageIdentity(taggedImage), messageIdentity(imageOnly)); assert.notEqual(messageIdentity(emptyText), messageIdentity(imageOnly)); }); + +// issue #330: an interrupted turn leaves its tool_calls unmatched. projectMessage +// keys off stopReason (not "missing result anywhere") so OMP execution roles / +// evicted-undo fixtures are untouched. These lock in both the drop AND that scope. +function interruptedAssistant(name: string, stopReason: string, text?: string): object { + return { + role: "assistant", + content: [ + ...(text ? [{ type: "text", text }] : []), + { type: "toolCall", id: "tcF", name, arguments: {} }, + ], + api: "anthropic", + provider: "anthropic", + model: "m", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason, + timestamp: Date.now(), + }; +} + +test("entriesToCoreMessages drops a dangling tool-call from an aborted turn — issue #330", () => { + const entries: SessionEntry[] = [ + msgEntry("a", user("keep working")), + msgEntry("f", interruptedAssistant("compress", "aborted")), + ]; + const core = entriesToCoreMessages(entries); + assert.ok(!core.some((m) => m.role === "assistant" && m.contentType === "tool-call"), "no dangling tool-call in sent view"); + assert.equal(core.length, 1, "only the preceding user message survives"); +}); + +test("entriesToCoreMessages drops a dangling tool-call on stopReason error too — issue #330", () => { + const entries: SessionEntry[] = [ + msgEntry("a", user("go")), + msgEntry("f", interruptedAssistant("compress", "error")), + ]; + const core = entriesToCoreMessages(entries); + assert.ok(!core.some((m) => m.role === "assistant" && m.contentType === "tool-call")); +}); + +test("entriesToCoreMessages keeps visible prose when an interrupted turn also had text — issue #330", () => { + const entries: SessionEntry[] = [ + msgEntry("a", user("go")), + msgEntry("f", interruptedAssistant("compress", "aborted", "Let me compress now")), + ]; + const core = entriesToCoreMessages(entries); + const assistant = core.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant prose kept"); + assert.equal(assistant!.contentType, "text"); + assert.equal(assistant!.text, "Let me compress now"); +}); + +test("entriesToCoreMessages keeps a normal toolUse call that has its result — control", () => { + const entries: SessionEntry[] = [ + msgEntry("a", user("go")), + msgEntry("c", assistantToolCall("read")), + msgEntry("d", toolResult("tc1", "read", "ok")), + ]; + const core = entriesToCoreMessages(entries); + assert.ok(core.some((m) => m.role === "assistant" && m.contentType === "tool-call"), "normal call preserved"); +}); + +test("entriesToCoreMessages does NOT drop a toolUse call merely because its result is absent — issue #330 scope", () => { + // We deliberately key off stopReason, not result-presence: a toolUse with no + // result yet is normal mid-stream state, not an interruption. + const entries: SessionEntry[] = [ + msgEntry("a", user("go")), + msgEntry("c", assistantToolCall("read")), + ]; + const core = entriesToCoreMessages(entries); + assert.ok(core.some((m) => m.role === "assistant" && m.contentType === "tool-call"), "toolUse w/o result retained (not interrupted)"); +}); diff --git a/tests/prompts-config.test.ts b/tests/prompts-config.test.ts index 0533100..06ea9d5 100644 --- a/tests/prompts-config.test.ts +++ b/tests/prompts-config.test.ts @@ -73,7 +73,7 @@ test("applyUserConfig flows prompts through to the adapter", () => { test("buildAcpSystemPrompt default output is byte-stable (no trailing whitespace, full rules embedded)", () => { const prompt = buildAcpSystemPrompt(defaultPrompts); assert.ok( - prompt.endsWith("If the user sends new input during a retry wait, the retry is cancelled.\n"), + prompt.endsWith("Compressing becomes available again on the next user message.\n"), "ends exactly like the master const — const->function refactor must not add trailing whitespace", ); assert.equal( @@ -81,6 +81,7 @@ test("buildAcpSystemPrompt default output is byte-stable (no trailing whitespace false, "no trailing whitespace before the final newline", ); + assert.ok(prompt.includes("[ACP:compress-loop]"), "compress-loop guard sentinel is documented"); assert.ok(prompt.includes(defaultPrompts.compressPhilosophy), "full compressPhilosophy embedded verbatim"); assert.ok(prompt.includes(defaultPrompts.howToCompressRules), "full howToCompressRules embedded verbatim"); assert.ok(prompt.includes(defaultPrompts.tier2DistillRules), "full tier2DistillRules embedded verbatim");