diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index b2460ff90a596..90d99699b3082 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -660,6 +660,159 @@ 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 an explicit compaction that OpenCode reports as a context overflow", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("compact-overflow", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + 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 }; + }, + }, + }); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* harness.startTurn("/compact"); + + 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, + ), + ); + it.effect("preserves native multi-select and custom-answer flags through a live reply", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index a1b5ccb008ebe..b9f2714fb9379 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -281,6 +281,7 @@ interface ActiveOpenCodeTurn { admissionMessageId: string | null; interrupted: boolean; finalized: boolean; + contextOverflow: OrchestrationV2ProviderFailure | null; planId: PlanId | null; admissionGeneration: number; admissionReconciliationGeneration: number | null; @@ -2045,6 +2046,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()) { @@ -2099,7 +2106,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" @@ -2258,6 +2265,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function admissionMessageId: null, interrupted: false, finalized: false, + contextOverflow: null, planId: null, admissionGeneration: 0, admissionReconciliationGeneration: null, @@ -2555,6 +2563,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; @@ -2657,6 +2666,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) @@ -3244,6 +3267,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function admissionMessageId, interrupted: false, finalized: false, + contextOverflow: null, planId: null, admissionGeneration: state.nextAdmissionGeneration++, admissionReconciliationGeneration: null, @@ -3280,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), ),