From 6370fdcf74a5b8a10d2c13d6cb8dcca10517eb26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 22:22:28 +0000 Subject: [PATCH 1/3] test(opencode): turns survive context overflows that OpenCode auto-compacts (red) Reproduces #26674. OpenCode 1.18.18 publishes session.error ContextOverflowError, then auto-compacts and keeps the session busy until it goes idle. The adapter finalizes the turn as failed at the error, so the post-compaction output and the real idle never belong to a T3 turn. The second test, overflow then idle with no session.compacted (auto-compaction off), passes on main. It guards the fix against completing a turn that OpenCode really did fail. Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 759545fff0d19..1890995676338 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -536,6 +536,174 @@ describe("OpenCodeAdapterV2", () => { ), ); + // OpenCode 1.18.18 reports a context overflow as session.error, then auto-compacts and continues. + it.effect("keeps a turn running through a context overflow that OpenCode auto-compacts", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + let promptId = ""; + const harness = yield* makeOpenCodeRuntimeHarness("context-overflow", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (input: { messageID: string }) => { + promptId = input.messageID; + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const status = (type: "busy" | "idle") => ({ + type: "session.status", + properties: { sessionID: "root", status: { type } }, + }); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { id: promptId, role: "user", time: { created: 1 } }, + }, + }), + ); + yield* Effect.promise(() => nativeEvents.push(status("busy"))); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "ContextOverflowError", + data: { + message: + "This model's maximum context length is 131070 tokens. However, your messages resulted in 140049 tokens.", + }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.compacted", properties: { sessionID: "root" } }), + ); + yield* Effect.promise(() => nativeEvents.push(status("busy"))); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: "root", + part: { + id: "post-compaction-text", + sessionID: "root", + messageID: "post-compaction-assistant", + type: "text", + text: "Post-compaction step", + time: { start: 2 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { + id: "post-compaction-assistant", + sessionID: "root", + role: "assistant", + time: { created: 2, completed: 3 }, + parentID: promptId, + }, + }, + }), + ); + yield* Effect.promise(() => nativeEvents.push(status("idle"))); + + const events = Array.from(yield* Fiber.join(received)); + const terminal = events.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "completed"); + assert.isTrue( + events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "Post-compaction step", + ), + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("fails a turn on a context overflow that OpenCode does not compact", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + let promptId = ""; + const harness = yield* makeOpenCodeRuntimeHarness("context-overflow-no-compact", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (input: { messageID: string }) => { + promptId = input.messageID; + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: "root", + info: { id: promptId, role: "user", time: { created: 1 } }, + }, + }), + ); + for (const event of [ + { type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }, + { + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "ContextOverflowError", + data: { message: "This model's maximum context length is 131070 tokens." }, + }, + }, + }, + { type: "session.status", properties: { sessionID: "root", status: { type: "idle" } } }, + ]) { + yield* Effect.promise(() => nativeEvents.push(event)); + } + + const terminal = Array.from(yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal( + terminal?.failure?.message, + "This model's maximum context length is 131070 tokens.", + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("preserves native multi-select and custom-answer flags through a live reply", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); From f7c3dc177b12a8d95b4e3720423f53e61375559e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 23:38:01 +0000 Subject: [PATCH 2/3] fix(opencode): a context overflow that OpenCode compacts no longer fails the turn OpenCode 1.x reports a ContextOverflowError as session.error, then stays busy, auto-compacts and continues the agent loop. The adapter finalized the turn as failed on that error, so T3 showed the run failed while OpenCode kept working unsupervised. The adapter now remembers the overflow on the active turn instead of finalizing. session.compacted clears it; if the session goes idle with the overflow still set (auto-compaction off, or compaction failed), the turn fails with the same provider failure as before. Co-Authored-By: Claude Opus 5.5 --- .../provider-opencode/src/server/adapter.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index ef85a7e70b29b..ef5212fe915af 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -279,6 +279,7 @@ interface ActiveOpenCodeTurn { admissionMessageId: string | null; interrupted: boolean; finalized: boolean; + contextOverflow: OrchestrationV2ProviderFailure | null; planId: PlanId | null; admissionGeneration: number; admissionReconciliationGeneration: number | null; @@ -2024,6 +2025,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function status = "failed"; terminal = { failure: nativeStreamFailure, threadDisposition: "broken" }; } + let lastError = sessionEntity.lastError; + if (status === "completed" && turn.contextOverflow !== null) { + status = "failed"; + terminal = { failure: turn.contextOverflow }; + lastError = turn.contextOverflow.message; + } turn.finalized = true; const completedAt = yield* DateTime.now; for (const part of turn.parts.values()) { @@ -2078,7 +2085,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ); yield* updateProviderSession( anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready", - status === "failed" ? sessionEntity.lastError : null, + status === "failed" ? lastError : null, ); yield* emitProviderEvent( status === "failed" @@ -2237,6 +2244,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function admissionMessageId: null, interrupted: false, finalized: false, + contextOverflow: null, planId: null, admissionGeneration: 0, admissionReconciliationGeneration: null, @@ -2534,6 +2542,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function case "session.compacted": { const state = threads.get(event.properties.sessionID); if (state?.activeTurn !== undefined && state.activeTurn !== null) { + state.activeTurn.contextOverflow = null; yield* emitCompactionItem(state, state.activeTurn); } return; @@ -2636,6 +2645,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function return; } case "session.error": { + const overflowTurn = + event.properties.error?.name === "ContextOverflowError" && + event.properties.sessionID !== undefined + ? threads.get(event.properties.sessionID)?.activeTurn + : undefined; + if (overflowTurn !== undefined && overflowTurn !== null) { + // OpenCode stays busy and auto-compacts; the turn fails only if it idles uncompacted. + overflowTurn.contextOverflow = makeProviderFailure({ + message: openCodeErrorMessage(event), + code: "ContextOverflowError", + class: "provider_error", + }); + return; + } const states = event.properties.sessionID === undefined ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) @@ -3200,6 +3223,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function admissionMessageId, interrupted: false, finalized: false, + contextOverflow: null, planId: null, admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, From 6b06929239a93f31ffc03312bcdf3274b58056d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:32:34 +0000 Subject: [PATCH 3/3] fix(opencode): an explicit compaction that overflows no longer shows as compacted Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 57 +++++++------------ .../provider-opencode/src/server/adapter.ts | 5 +- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 414e24617bd3a..90d99699b3082 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -766,62 +766,47 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("fails a turn on a context overflow that OpenCode does not compact", () => + it.effect("fails an explicit compaction that OpenCode reports as a context overflow", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); - let promptId = ""; - const harness = yield* makeOpenCodeRuntimeHarness("context-overflow-no-compact", "root", { + const harness = yield* makeOpenCodeRuntimeHarness("compact-overflow", "root", { event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, session: { create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async (input: { messageID: string }) => { - promptId = input.messageID; + summarize: async () => { + await nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "ContextOverflowError", + data: { message: "This model's maximum context length is 131070 tokens." }, + }, + }, + }); return { data: true }; }, - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), }, }); - yield* harness.startTurn(); const received = yield* harness.runtime.events.pipe( Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, Effect.forkScoped, ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.updated", - properties: { - sessionID: "root", - info: { id: promptId, role: "user", time: { created: 1 } }, - }, - }), - ); - for (const event of [ - { type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }, - { - type: "session.error", - properties: { - sessionID: "root", - error: { - name: "ContextOverflowError", - data: { message: "This model's maximum context length is 131070 tokens." }, - }, - }, - }, - { type: "session.status", properties: { sessionID: "root", status: { type: "idle" } } }, - ]) { - yield* Effect.promise(() => nativeEvents.push(event)); - } + yield* harness.startTurn("/compact"); - const terminal = Array.from(yield* Fiber.join(received)).find( - (event) => event.type === "turn.terminal", - ); + const events = Array.from(yield* Fiber.join(received)); + const terminal = events.find((event) => event.type === "turn.terminal"); assert.equal(terminal?.status, "failed"); assert.equal( terminal?.failure?.message, "This model's maximum context length is 131070 tokens.", ); + assert.isFalse( + events.some( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + ); }).pipe( Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), Effect.scoped, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index f6783a7261919..b9f2714fb9379 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3304,7 +3304,10 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ), ).pipe( Effect.tap(() => - turn.interrupted || turn.finalized || nativeStreamFailure !== null + turn.interrupted || + turn.finalized || + turn.contextOverflow !== null || + nativeStreamFailure !== null ? Effect.void : emitCompactionItem(state, turn), ),