Skip to content

Compaction shrinking restores text removed by the compaction hook #54916

Open

Description

@daniel-sc

Description

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

  1. Seed a completed checkpoint with a generated canary in both summary and recent.
  2. Add an oversized older exchange and smaller newer exchanges; use a small model context limit and keep: 0 to force shrinking.
  3. Register a compaction hook that replaces the canary in event.messages.
  4. 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.

Screenshot and/or share link

No response

Operating System

Ubuntu 26.04

Terminal

No response

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    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:

    1. Create a completed checkpoint whose summary contains a canary.
    2. Add an oversized exchange, then two smaller ones.
    3. Set keep: 0 and buffer: 3_000 and switch to a model with a 7k context, so the compaction request has to be shrunk.
    4. 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 sys
    p = 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 src
    src = src.replace(anchor, new + anchor, 1)
    open(p, "w").write(src)
    PY
    cd packages/core && bun test test/session-runner.test.ts -t "REPRO-54916" 2>&1 | tail -40

    Run it: bash repro.sh

  2. iceteaSA commented on Oct 22, 2026

    @iceteaSA

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions