From 725ed03cebbc9d662c3b8323a12014ea6fefef32 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 5 Nov 2026 00:48:46 +0530 Subject: [PATCH 1/2] fix(opencode): free-tier usage limits show when they reset Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 26 ++++++++++ .../src/server/v2/OpenCode2Client.test.ts | 52 +++++++++++++++++++ .../src/server/v2/OpenCode2Client.ts | 23 +++++++- .../src/server/v2/adapter.ts | 12 ++++- 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e4e0851e82c29..9a90bdd20b317 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -636,6 +636,32 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("classifies a free-tier limit as a usage limit that resets after retryAfterMs", () => + 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: "" }), + promptAccepted, + event("session.execution.failed", { + sessionID: SESSION, + error: { type: "provider.free-limit", message, status: 429, retryAfterMs: 21_486_000 }, + }), + ]); + 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: "1970-01-01T05:58:06.000Z", + }); + }).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([ diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 5ce763e024b86..3b96c0d3d2bec 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -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"; @@ -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", diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 71fb1d70ce7d4..bf15756a08af8 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -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); @@ -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(decoded.success)) + ? Effect.succeed( + Result.succeed(withRetryAfter(decoded.success, frame.data)), + ) : undecodable(frame.data); }), ), diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index ddca16ed8fed3..b49f86aa006a7 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -2165,20 +2165,30 @@ 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; + const failedAt = yield* DateTime.now; + const retryAt = + retryAfterMs === undefined + ? null + : DateTime.formatIso(DateTime.addDuration(failedAt, retryAfterMs)); 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, }), }); } From 5971abad84f1007cb6259f1c851c6025ed905554 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 5 Nov 2026 01:16:57 +0530 Subject: [PATCH 2/2] fix(opencode): base free-limit reset on the event time and ignore out-of-range delays Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 17 ++++++++++++++--- .../provider-opencode/src/server/v2/adapter.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 9a90bdd20b317..b206e7c34ff2d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -636,7 +636,18 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("classifies a free-tier limit as a usage limit that resets after retryAfterMs", () => + // 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."; @@ -645,7 +656,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { promptAccepted, event("session.execution.failed", { sessionID: SESSION, - error: { type: "provider.free-limit", message, status: 429, retryAfterMs: 21_486_000 }, + error: { type: "provider.free-limit", message, status: 429, retryAfterMs }, }), ]); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); @@ -657,7 +668,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { code: "provider.free-limit", message, retryable: null, - resetAt: "1970-01-01T05:58:06.000Z", + ...("resetAt" in rest ? { resetAt: rest.resetAt } : {}), }); }).pipe(Effect.scoped), ); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index b49f86aa006a7..aa68d17336003 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -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"; @@ -2177,11 +2178,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr type === "provider.rate-limit" || type === "provider.free-limit" || claudeLimit !== undefined; - const failedAt = yield* DateTime.now; + // A delay past the date range has no reset time; it must not fail the event. const retryAt = retryAfterMs === undefined ? null - : DateTime.formatIso(DateTime.addDuration(failedAt, retryAfterMs)); + : Option.match(DateTime.make(event.created + retryAfterMs), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({