fix(opencode): stop overflow compaction from replaying a message that cannot fit
A single message larger than the model's context window used to make opencode loop forever: overflow, compact, replay the same message, overflow again. In production this ran 2,145 cycles over 12.8 hours on one customer session ($296) before it was killed by hand. After this change the second overflow compaction in a row fails the turn with a clear error instead.
Context: Replo postmortem REPL-31509, companion tickets REPL-31502 (attachment size cap) and REPL-31503 (Retry on overflow). The coordinator side gets a circuit breaker in a separate andytown PR; this is the fix at the source.
Why the loop never ended. compaction.process({ overflow: true }) replays the last real user message after the summary so the request is not lost. The only existing guard fires when the summary call overflows. Here the summary always succeeded (~580k tokens) and the replayed message is what overflowed, one step later, which fed straight back into compaction.create(auto: true).
Changes
- In
processCompaction, before calling the model: if this is an overflow compaction and no finished, non-errored assistant step has happened since the previous summary, the conversation is stalled. Write the summary row asfinish: "error"with aContextOverflowErrorexplaining the message is too large, and return"stop". Writing the error on the summary row consumes the pending compaction task, so the next prompt does not re-fire it. - Also refuse the replay when
Token.estimateof the replayed parts alone exceeds the usable window. This catches the obvious cases without paying for a summary; the stall check is the one that catches token-dense content the estimator undercounts (base64, markup). - The stall check reads
input.messages, not the trimmedhistory: with a replay,historyhas already dropped the replayed turn, which is exactly where progress would show.
Cost bound after this change: one oversized message costs at most two compaction summaries before the turn fails, instead of unbounded.
Testing Done
- New tests in
test/session/compaction.test.ts: a summary followed by a replayed message that errored, then another overflow compaction, returns"stop"with the errored summary row; a summary followed by a finished step and then an overflow compaction still returns"continue". bun test test/session/compaction.test.ts -t overflowpasses (4/4). The full file has pre-existing timing-sensitive tests that time out on this laptop under the default 5s budget; with--timeout 30000the only remaining failure isstops quickly when aborted during retry backoff, a wall-clock assertion unrelated to this change.bun run typecheckandprettier --checkpass on the changed files.
š¤ Generated with Claude Code
Summary by cubic
Stops overflow compaction from replaying a message that cannot fit the context window, which previously looped forever (2,145 cycles over 12.8 hours in one production session). The second overflow compaction in a row now fails the turn with a clear error.
- Fails the turn when an overflow compaction follows a summary that produced no finished assistant step, writing the error on the summary row so the pending compaction task is consumed.
- Refuses the replay when the estimated token size of the replayed parts exceeds the usable window; the stall check catches token-dense content the estimator undercounts.