Conversation
… cannot fit An overflow compaction whose previous summary produced no finished assistant step is a loop, not progress: the replayed user message itself exceeds the window. Fail the turn on the summary row (consuming the pending compaction task) instead of compacting and replaying again. Also refuse the replay outright when its estimated size alone exceeds the usable window. REPL-31509 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: |
| !input.messages | ||
| .slice(previous.assistantIndex + 1) | ||
| .some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error) |
There was a problem hiding this comment.
🔴 Retained history defeats loop guard
When a prior compaction preserves completed history, stalled mistakes those reordered old replies for post-summary progress. Oversized messages can resume the unbounded compaction loop.
Prompt for agents
The stalled-overflow check in packages/opencode/src/session/compaction.ts uses array position to identify assistant replies after the previous summary. MessageV2.filterCompacted can reorder a retained pre-compaction tail after that summary, so old finished assistants satisfy the progress predicate. Compare chronological message identity instead, such as monotonic message IDs relative to the previous summary, while preserving correct behavior for both reordered retained tails and ordinary chronological transcripts. Add a regression test that creates a completed compaction with tail_start_id, replays an oversized message, records its overflow error, and verifies the next overflow compaction stops.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const replayTooLarge = | ||
| replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model }) |
There was a problem hiding this comment.
🟡 Replay size uses wrong request
replayTooLarge measures stored parts against the compaction model, not the stripped request against the replay model. Valid replays can fail while oversized ones pass.
Prompt for agents
The replayTooLarge guard in packages/opencode/src/session/compaction.ts does not measure the request that will be replayed. Replay creation later replaces media files with short text labels, and the next provider turn resolves replay.info.model, while this guard serializes the original stored parts and uses the compaction agent model. Build or estimate the same transformed replay payload that the next turn consumes and compare it against the original user's resolved model and usable window. Cover a large data-URL attachment that becomes a small placeholder and configurations where the compaction and user models have different limits.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
3 issues found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/src/session/compaction.ts">
<violation number="1" location="packages/opencode/src/session/compaction.ts:395">
P1: Identify assistant progress by message identity or chronology instead of the array index. Retained pre-compaction messages can be reordered after the summary, allowing this slice to count an old finished assistant as new progress and continue the oversized-message compaction loop.</violation>
<violation number="2" location="packages/opencode/src/session/compaction.ts:398">
P2: When an overflow replay includes a data-URL media attachment, this check counts the full base64 URL and stops before replay's media stripping runs. Measure the transformed replay payload so large attachments retain the existing replay-with-placeholder path.</violation>
</file>
<file name="packages/opencode/test/session/compaction.test.ts">
<violation number="1" location="packages/opencode/test/session/compaction.test.ts:902">
P3: Neither new test exercises the replay-size guard. Both use small replayed text parts, so the branch `replayTooLarge` (Token.estimate of the replayed parts exceeding the usable window) is never taken in the test suite, leaving that new overflow-termination path uncovered. Consider a test where the replayed part alone exceeds the window to lock in that behavior.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| input.overflow === true && | ||
| previous !== undefined && | ||
| !input.messages | ||
| .slice(previous.assistantIndex + 1) |
There was a problem hiding this comment.
P1: Identify assistant progress by message identity or chronology instead of the array index. Retained pre-compaction messages can be reordered after the summary, allowing this slice to count an old finished assistant as new progress and continue the oversized-message compaction loop.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/compaction.ts, line 395:
<comment>Identify assistant progress by message identity or chronology instead of the array index. Retained pre-compaction messages can be reordered after the summary, allowing this slice to count an old finished assistant as new progress and continue the oversized-message compaction loop.</comment>
<file context>
@@ -380,6 +380,38 @@ const layer = Layer.effect(
+ input.overflow === true &&
+ previous !== undefined &&
+ !input.messages
+ .slice(previous.assistantIndex + 1)
+ .some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error)
+ const replayTooLarge =
</file context>
| .slice(previous.assistantIndex + 1) | ||
| .some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error) | ||
| const replayTooLarge = | ||
| replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model }) |
There was a problem hiding this comment.
P2: When an overflow replay includes a data-URL media attachment, this check counts the full base64 URL and stops before replay's media stripping runs. Measure the transformed replay payload so large attachments retain the existing replay-with-placeholder path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/compaction.ts, line 398:
<comment>When an overflow replay includes a data-URL media attachment, this check counts the full base64 URL and stops before replay's media stripping runs. Measure the transformed replay payload so large attachments retain the existing replay-with-placeholder path.</comment>
<file context>
@@ -380,6 +380,38 @@ const layer = Layer.effect(
+ .slice(previous.assistantIndex + 1)
+ .some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error)
+ const replayTooLarge =
+ replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model })
+ if (stalled || replayTooLarge) {
+ msg.error = new SessionV1.ContextOverflowError({
</file context>
| replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model }) | |
| replay !== undefined && | |
| Token.estimate( | |
| JSON.stringify( | |
| replay.parts.map((part) => | |
| part.type === "file" && MessageV2.isMedia(part.mime) | |
| ? { type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } | |
| : part, | |
| ), | |
| ), | |
| ) >= usable({ cfg, model }) |
| // overflowed again ~2,145 times. The second overflow compaction after a | ||
| // summary that produced no finished step must fail the turn instead. | ||
| itCompaction.instance( | ||
| "stops an overflow compaction when the previous summary produced no finished step", |
There was a problem hiding this comment.
P3: Neither new test exercises the replay-size guard. Both use small replayed text parts, so the branch replayTooLarge (Token.estimate of the replayed parts exceeding the usable window) is never taken in the test suite, leaving that new overflow-termination path uncovered. Consider a test where the replayed part alone exceeds the window to lock in that behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/session/compaction.test.ts, line 902:
<comment>Neither new test exercises the replay-size guard. Both use small replayed text parts, so the branch `replayTooLarge` (Token.estimate of the replayed parts exceeding the usable window) is never taken in the test suite, leaving that new overflow-termination path uncovered. Consider a test where the replayed part alone exceeds the window to lock in that behavior.</comment>
<file context>
@@ -895,6 +895,116 @@ describe("session.compaction.process", () => {
+ // overflowed again ~2,145 times. The second overflow compaction after a
+ // summary that produced no finished step must fail the turn instead.
+ itCompaction.instance(
+ "stops an overflow compaction when the previous summary produced no finished step",
+ Effect.gen(function* () {
+ const test = yield* TestInstance
</file context>
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 intocompaction.create(auto: true).Changes
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.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).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
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.
Written for commit 2a4345f. Summary will update on new commits.