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
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,43 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped),
);

// The delay counts from the event's own time (created = 1 ms), not the test clock (0 ms).
it.effect.each([
{
name: "resets after retryAfterMs",
retryAfterMs: 21_486_000,
resetAt: "1970-01-01T05:58:06.001Z",
},
{
name: "has no reset when retryAfterMs is past the date range",
retryAfterMs: Number.MAX_SAFE_INTEGER,
},
] as const)("classifies a free-tier limit that $name", ({ retryAfterMs, ...rest }) =>
Effect.gen(function* () {
const message =
"Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models.";
const { runtime, thread } = yield* resumed([
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.failed", {
sessionID: SESSION,
error: { type: "provider.free-limit", message, status: 429, retryAfterMs },
}),
]);
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn(turnInput(thread));
const ended = yield* Fiber.join(terminal);
assert.equal(ended?.status, "failed");
assert.deepEqual(ended?.failure, {
class: "usage_limit",
code: "provider.free-limit",
message,
retryable: null,
...("resetAt" in rest ? { resetAt: rest.resetAt } : {}),
});
}).pipe(Effect.scoped),
);

it.effect("ends the turn when its terminal event is one this build cannot decode", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
Expand Down
52 changes: 52 additions & 0 deletions packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
Expand Down Expand Up @@ -82,6 +84,56 @@ describe("OpenCode2Client event subscription", () => {
);
});

describe("OpenCode2Client execution failures", () => {
// The bundled 2.0.28 schema strips retryAfterMs, which carries a free-tier limit's reset.
it.effect("keeps retryAfterMs on a failed execution event", () =>
Effect.gen(function* () {
const frame = {
id: "evt_failed0000",
created: 1,
type: "session.execution.failed",
data: {
sessionID: "ses_repro",
error: {
type: "provider.free-limit",
message: "Free usage limit reached.",
status: 429,
retryAfterMs: 21_486_000,
},
},
durable: { aggregateID: "ses_repro", seq: 1, version: 1 },
};
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(frame)}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
);
const opencode = yield* OpenCode2Client.OpenCode2Client.pipe(
Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))),
);
const { events } = yield* opencode.connect({
baseUrl: "http://127.0.0.1:4096",
password: "test-password",
});
const first = yield* events.pipe(Effect.flatMap(Stream.runHead));
const event = Option.getOrThrow(first);
assert.strictEqual(event.type, "session.execution.failed");
assert.deepEqual(
event.type === "session.execution.failed" ? event.data.error : undefined,
frame.data.error,
);
}).pipe(Effect.scoped),
);
});

const previous = {
id: "msg_repro088",
type: "synthetic",
Expand Down
23 changes: 22 additions & 1 deletion packages/provider-opencode/src/server/v2/OpenCode2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ const decodeEnvelope = Schema.decodeUnknownOption(
),
);

// The bundled schema drops retryAfterMs from a failure, so read it from the raw frame.
const decodeRetryAfter = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
data: Schema.Struct({
error: Schema.Struct({ retryAfterMs: Schema.optional(Schema.Int) }),
}),
}),
),
);

const withRetryAfter = (event: OpenCodeEvent, frame: string): OpenCodeEvent => {
if (event.type !== "session.execution.failed") return event;
const retryAfterMs = Option.getOrUndefined(decodeRetryAfter(frame))?.data.error.retryAfterMs;
if (retryAfterMs === undefined) return event;
const error = { ...event.data.error, retryAfterMs };
return { ...event, data: { ...event.data, error } };
};

// Rejected stream frames can be frequent when the server introduces new event types.
const undecodable = Effect.fnUntraced(function* (data: string) {
const envelope = decodeEnvelope(data);
Expand Down Expand Up @@ -130,7 +149,9 @@ const readEvents = HttpClient.get("/api/event", {
Stream.filterMapEffect((frame) => {
const decoded = decodeEvent(frame.data);
return Result.isSuccess(decoded)
? Effect.succeed(Result.succeed<OpenCode2StreamEvent>(decoded.success))
? Effect.succeed(
Result.succeed<OpenCode2StreamEvent>(withRetryAfter(decoded.success, frame.data)),
)
: undecodable(frame.data);
}),
),
Expand Down
16 changes: 15 additions & 1 deletion packages/provider-opencode/src/server/v2/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import * as Effect from "effect/Effect";
import * as Hex from "effect/encoding/Hex";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Schedule from "effect/Schedule";
import * as Scope from "effect/Scope";
Expand Down Expand Up @@ -2165,20 +2166,33 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
return yield* endExecution(state, turn, { status: "interrupted" });
case "session.execution.failed": {
const { type, message, status, response } = event.data.error;
const retryAfterMs =
"retryAfterMs" in event.data.error && typeof event.data.error.retryAfterMs === "number"
? event.data.error.retryAfterMs
: undefined;
// OpenCode types an empty balance as provider.quota too, but it answers
// that with HTTP 402 and it never resets. Usage windows and budgets are 429.
const claudeLimit = claudeUsageLimit(message);
const usageLimit =
(type === "provider.quota" && status !== 402) ||
type === "provider.rate-limit" ||
type === "provider.free-limit" ||
claudeLimit !== undefined;
// A delay past the date range has no reset time; it must not fail the event.
const retryAt =
retryAfterMs === undefined
? null
: Option.match(DateTime.make(event.created + retryAfterMs), {
onNone: () => null,
onSome: DateTime.formatIso,
});
return yield* endExecution(state, turn, {
status: "failed",
failure: makeProviderFailure({
message: readableProviderErrorMessage(message, status, response?.body),
code: type,
class: usageLimit ? "usage_limit" : "provider_error",
resetAt: claudeLimit?.resetAt ?? null,
resetAt: claudeLimit?.resetAt ?? retryAt,
}),
});
}
Expand Down
Loading