Skip to content

fix(opencode): stop overflow compaction from replaying a message that cannot fit - #39

Open
yuxinzhu wants to merge 1 commit into
devfrom
yuxin/overflow-compaction-stall
Open

yuxinzhu wants to merge 1 commit into
devfrom
yuxin/overflow-compaction-stall

Conversation

@yuxinzhu

@yuxinzhu yuxinzhu commented Sep 3, 2026

Copy link
Copy Markdown

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 as finish: "error" with a ContextOverflowError explaining 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.estimate of 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 trimmed history: with a replay, history has 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 overflow passes (4/4). The full file has pre-existing timing-sensitive tests that time out on this laptop under the default 5s budget; with --timeout 30000 the only remaining failure is stops quickly when aborted during retry backoff, a wall-clock assertion unrelated to this change.
  • bun run typecheck and prettier --check pass on the changed files.

🤖 Generated with Claude Code


Devin Review

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.

Written for commit 2a4345f. Summary will update on new commits.

Review in cubic

… 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>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +394 to +396
!input.messages
.slice(previous.assistantIndex + 1)
.some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +397 to +398
const replayTooLarge =
replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant