Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,38 @@ const layer = Layer.effect(
},
}
yield* session.updateMessage(msg)

// NOTE (Yuxin, 2026-09-03, REPL-31509): an overflow compaction whose previous
// summary produced no finished assistant step is a loop, not progress — the
// replayed message itself does not fit. Fail the turn on the summary row so
// the pending compaction task is consumed instead of re-firing next prompt.
// Checked against the full transcript: with a replay, `history` already
// dropped the replayed turn, which is exactly where progress would show.
const previous = prior.at(-1)
const stalled =
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>

.some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error)
Comment on lines +394 to +396

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.

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

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.

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

if (stalled || replayTooLarge) {
msg.error = new SessionV1.ContextOverflowError({
message:
"The last message is too large for the model's context window even after compaction. Remove or shorten the large attachment, or start a new chat.",
}).toObject()
msg.finish = "error"
msg.time.completed = Date.now()
yield* session.updateMessage(msg)
yield* Effect.logWarning("compaction stalled on oversized message", {
sessionID: input.sessionID,
stalled,
replayTooLarge,
})
return "stop"
}

const processor = yield* processors.create({
assistantMessage: msg,
sessionID: input.sessionID,
Expand Down
110 changes: 110 additions & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,116 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ result: "compact" })),
)

// REPL-31509: an oversized message overflowed, was compacted, replayed, and
// 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>

Effect.gen(function* () {
const test = yield* TestInstance
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "make this page")
const firstMarker = yield* createUserMessage(session.id, "")
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: firstMarker.id,
sessionID: session.id,
type: "compaction",
auto: true,
overflow: true,
})
yield* createSummaryAssistantMessage(session.id, firstMarker.id, test.directory, "summary")
const replayed = yield* createUserMessage(session.id, "make this page")
yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "assistant",
sessionID: session.id,
mode: "build",
agent: "build",
path: { cwd: test.directory, root: test.directory },
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
parentID: replayed.id,
time: { created: Date.now() },
error: new SessionV1.ContextOverflowError({ message: "prompt is too long" }).toObject(),
})
const secondMarker = yield* createUserMessage(session.id, "")
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: secondMarker.id,
sessionID: session.id,
type: "compaction",
auto: true,
overflow: true,
})
const msgs = yield* ssn.messages({ sessionID: session.id })

const result = yield* SessionCompaction.use.process({
parentID: secondMarker.id,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
})

const summaries = (yield* ssn.messages({ sessionID: session.id })).filter(
(msg) => msg.info.role === "assistant" && msg.info.summary,
)
const last = summaries.at(-1)
expect(result).toBe("stop")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.finish).toBe("error")
expect(JSON.stringify(last.info.error)).toContain("too large for the model's context window")
}
}).pipe(withCompaction({ result: "continue" })),
)

itCompaction.instance(
"still compacts on overflow when a finished step followed the previous summary",
Effect.gen(function* () {
const test = yield* TestInstance
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "make this page")
const firstMarker = yield* createUserMessage(session.id, "")
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: firstMarker.id,
sessionID: session.id,
type: "compaction",
auto: true,
overflow: true,
})
yield* createSummaryAssistantMessage(session.id, firstMarker.id, test.directory, "summary")
const next = yield* createUserMessage(session.id, "now tweak the header")
yield* createAssistantMessage(session.id, next.id, test.directory)
const secondMarker = yield* createUserMessage(session.id, "")
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: secondMarker.id,
sessionID: session.id,
type: "compaction",
auto: true,
overflow: true,
})
const msgs = yield* ssn.messages({ sessionID: session.id })

const result = yield* SessionCompaction.use.process({
parentID: secondMarker.id,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
})

expect(result).toBe("continue")
}).pipe(withCompaction({ result: "continue" })),
)

it.instance(
"adds synthetic continue prompt when auto is enabled",
Effect.gen(function* () {
Expand Down
Loading