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
168 changes: 168 additions & 0 deletions packages/provider-opencode/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 25 additions & 1 deletion packages/provider-opencode/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ interface ActiveOpenCodeTurn {
admissionMessageId: string | null;
interrupted: boolean;
finalized: boolean;
contextOverflow: OrchestrationV2ProviderFailure | null;
planId: PlanId | null;
admissionGeneration: number;
admissionReconciliationGeneration: number | null;
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading