fix(opencode): converge overflow compaction instead of replaying the same message forever

Merged
PI
2mo ago
+103-14

When a message is too large for the model, opencode compacts the conversation and re-sends the message. The retained window kept the original copy right next to the re-sent one, so the retry was exactly as large as the failure and the loop never ended. One production session repeated this 2,161 times over 12.8 hours. After this change the retry carries a single copy, and a turn that still overflows after one compaction stops with an error instead of compacting again.

Context

  • Postmortem: REPL-31509. A 614 KB .html attachment (96% one base64 image, ~580k tokens) was sent, stopped, and re-sent, so the context held two copies and Anthropic rejected it. Every compaction cycle then summarized the history before the message and appended a fresh copy of it, while filterCompacted still returned the original, so each retry was ~1.21M tokens.
  • Companion: andytown #26615 adds a coordinator-side breaker that aborts a session after three identical errors. That stops the bleeding; this PR fixes the mechanism so the loop cannot form.
  • Upstream anomalyco/opencode dev (a935432b5, 2026-09-03) has the same overflow replay code and the same filterCompacted, so this cannot be picked up by a base bump. Worth upstreaming after it bakes here.

Changes

Retained window on an overflow replay (session/compaction.ts). processCompaction computes tail_start_id from the history before the message it is about to replay, which always leaves that message inside the retained window. When a replay is emitted, the marker now retains nothing before itself, so the next model context is [marker, summary, replay]: one copy.

One overflow compaction per turn (session/prompt.ts). A message that still overflows after compaction replayed it can never fit. The second consecutive overflow ends the turn with ContextOverflowError on the assistant message instead of compacting again. A finished model step resets the counter.

Testing Done

  • bun test test/session/compaction.test.ts test/session/prompt.test.ts test/session/message-v2.test.ts: 150 pass.
  • New compaction test builds the incident shape (small turn, big message with a reply, the same big message re-sent, overflow marker) and asserts the post-compaction context is [marker, summary, replay] with the re-sent original gone. It fails on dev (two extra copies retained) and passes here.
  • New prompt-loop test drives a 413 โ†’ summary โ†’ 413 sequence through the real loop and asserts three provider hits, one compaction marker, and an assistant ending in ContextOverflowError / finish: "error".
  • bun run typecheck (tsgo) clean.

Rollout

Cut v1.17.14-11 from dev after merge, then bump OPENCODE_VERSION in apps/daytona-snapshot/Dockerfile and sandbox-packages/sandbox-upgrader/src/opencode-version.ts in andytown in one PR.

๐Ÿค– Generated with Claude Code


Summary by cubic

Fixes the overflow compaction loop that retried an oversized message forever, because the original copy was retained next to the replay. Now the retained window only keeps the replay, and a second consecutive overflow ends the turn with ContextOverflowError instead of compacting again. This addresses the runaway loop from REPL-31509.

  • In compaction.ts, the compaction marker now retains nothing before itself when replaying, so the next context is [marker, summary, replay].
  • In prompt.ts, the loop stops after one overflow compaction per turn and marks the assistant message as failed with ContextOverflowError.