You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Compaction shrinking restores text removed by the compaction hook #54916
When a compaction request needs shrinking, flattenAndDropOldest rebuilds the previous checkpoint's summary and recent text from stored history. This discards edits made by ctx.session.hook("compaction"), so text a redaction plugin removed can reach the model again.
The documented hook contract permits editing outgoing messages without changing stored history. Expected: shrinking preserves those edits. Actual: the original checkpoint text is restored, and the compaction hook is not rerun.
Plugins
Minimal compaction redaction hook. (SecretSieve)
OpenCode version
2.0.27; also reproduced on dev at 80fb31ebb7e8dfa5c26fc51ca81da8e06b618a52.
Steps to reproduce
Seed a completed checkpoint with a generated canary in both summary and recent.
Add an oversized older exchange and smaller newer exchanges; use a small model context limit and keep: 0 to force shrinking.
Register a compaction hook that replaces the canary in event.messages.
Capture the request passed to the LLM client. The hook output has no canary, but the dispatched request contains it.
Reproduced in the existing compaction test harness without a live provider or real secrets. A regression test and focused fix are prepared for a follow-up PR.
Thanks for the detailed report. I reproduced this on the latest dev (80fb31eb).
The script below adds a temporary scenario to the core session-runner tests, using the simulated model:
Create a completed checkpoint whose summary contains a canary.
Add an oversized exchange, then two smaller ones.
Set keep: 0 and buffer: 3_000 and switch to a model with a 7k context, so the compaction request has to be shrunk.
Register a compaction hook that replaces the canary in event.messages.
The hook's output has no canary, but the request actually sent (the text version with [1 older exchange omitted]) starts with the checkpoint rebuilt from storage, and the canary is in its <summary>. This matches the TODO in flattenAndDropOldest. compaction.ts is the same in 2.0.27 and on dev.
My setup only showed the leak through the summary; I didn't confirm it through the checkpoint's recent text, which is read from storage the same way. Maintainers will look at your fix branch when the PR comes in.
Reproduction script (repro.sh)
#!/usr/bin/env bash# Reproduces #54916: compaction shrinking (flattenAndDropOldest) restores checkpoint text that a# `compaction` session hook removed from event.messages.# Usage: REPO=/path/to/opencode-checkout bash repro.sh (needs `bun install` done in REPO)set -euo pipefail
REPO=${REPO:-/workspace/repo}cd"$REPO"
TEST=packages/core/test/session-runner.test.ts
cp "$TEST" /tmp/session-runner.test.ts.bak
trap'cp /tmp/session-runner.test.ts.bak "$REPO/$TEST"' EXIT
python3 - "$TEST"<<'PY'import sysp = sys.argv[1]src = open(p).read()anchor = ' scenario("records cancelled manual compaction without surfacing an internal failure"'new = r''' scenario("REPRO-54916 shrinking keeps compaction hook edits", function* (s) { const hooks = yield* PluginHooks.Service const service = yield* SessionCompaction.Service const CANARY = "CANARY-7f3a91" // 1. Build a completed checkpoint whose summary and recent text both contain the canary. s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 }) yield* s.llm.push(TestLLM.text(`Old answer ${CANARY}`, "old-answer")) yield* s.runPrompt(`Old question ${CANARY}`) yield* s.llm.push(TestLLM.text(`## Objective\n- Summary mentions ${CANARY}`, "summary-1")) yield* s.session.compact({ sessionID }) yield* s.resume const checkpoint = (yield* s.messages).findLast((m: any) => m.type === "compaction") as any console.log("checkpoint summary has canary:", String(checkpoint?.summary).includes(CANARY)) console.log("checkpoint recent has canary:", String(checkpoint?.recent).includes(CANARY)) // 2. An oversized older exchange, then smaller newer ones. yield* s.llm.push(...Array.from({ length: 3 }, (_, i) => TestLLM.text(`Answer ${i}`, `answer-${i}`))) yield* s.runPrompt(`Big request: ${"x".repeat(20_000)}`) yield* s.runPrompt("Small request 1") yield* s.runPrompt("Small request 2") // 3. Force shrinking and register a redaction hook. yield* service.transform((editor) => editor.configure({ buffer: 3_000, keep: 0 })) s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 }) let hookOutput = "" yield* hooks.register("session", "compaction", (event: any) => Effect.sync(() => { const redact = (v: any): any => typeof v === "string" ? v.split(CANARY).join("[REDACTED]") : Array.isArray(v) ? v.map(redact) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redact(x)])) : v event.messages.splice(0, event.messages.length, ...redact([...event.messages])) hookOutput = JSON.stringify(event.messages) }), ) s.requests.length = 0 yield* s.llm.push(TestLLM.text("## Objective\n- Recovered", "summary-2")) yield* s.session.compact({ sessionID }) yield* s.resume const sent = JSON.stringify(s.requests.at(-1)?.messages) console.log("hook output contains canary:", hookOutput.includes(CANARY)) console.log("request was shrunk:", sent.includes("omitted")) console.log("dispatched request contains canary:", sent.includes(CANARY)) expect(hookOutput.includes(CANARY)).toBeFalse() expect(sent).not.toContain(CANARY) })'''assert anchor in srcsrc = src.replace(anchor, new + anchor, 1)open(p, "w").write(src)PYcd packages/core && bun test test/session-runner.test.ts -t "REPRO-54916"2>&1| tail -40
This continues to occur on 2.1.1 and on dev at 27cdf5fb. flattenAndDropOldest rebuilds the previous summary and its recent text from context.messages. The TODO at compaction.ts#L497-L498 states that this drops the hook's edits.
One more path into this condition: SessionRecentText includes stored tool errors in the recent text (recent-text.ts#L44-L49). A redaction plugin can mask a tool error in all requests it sees, but the stored error stays raw. The fallback then sends that raw text to the provider. We reproduced this with the production flattenAndDropOldest code and a redaction hook, not with a live provider turn.
Activity
opencode-agent commentedon Oct 12, 2026
Thanks for the detailed report. I reproduced this on the latest
dev(80fb31eb).The script below adds a temporary scenario to the core session-runner tests, using the simulated model:
keep: 0andbuffer: 3_000and switch to a model with a 7k context, so the compaction request has to be shrunk.compactionhook that replaces the canary inevent.messages.The hook's output has no canary, but the request actually sent (the text version with
[1 older exchange omitted]) starts with the checkpoint rebuilt from storage, and the canary is in its<summary>. This matches the TODO inflattenAndDropOldest.compaction.tsis the same in 2.0.27 and ondev.My setup only showed the leak through the summary; I didn't confirm it through the checkpoint's recent text, which is read from storage the same way. Maintainers will look at your fix branch when the PR comes in.
Reproduction script (repro.sh)
Run it:
bash repro.shiceteaSA commentedon Oct 22, 2026
This continues to occur on 2.1.1 and on
devat27cdf5fb.flattenAndDropOldestrebuilds the previous summary and its recent text fromcontext.messages. The TODO at compaction.ts#L497-L498 states that this drops the hook's edits.One more path into this condition:
SessionRecentTextincludes stored tool errors in the recent text (recent-text.ts#L44-L49). A redaction plugin can mask a tool error in all requests it sees, but the stored error stays raw. The fallback then sends that raw text to the provider. We reproduced this with the productionflattenAndDropOldestcode and a redaction hook, not with a live provider turn.