diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json index 1517ff3ce941..3e4859de8425 100644 --- a/packages/provider-opencode/package.json +++ b/packages/provider-opencode/package.json @@ -63,6 +63,7 @@ "@effect/vitest": "catalog:", "@t3tools/provider-testing": "workspace:*", "@types/node": "catalog:", + "undici": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.fetch.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.fetch.test.ts new file mode 100644 index 000000000000..a5d3c1817150 --- /dev/null +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.fetch.test.ts @@ -0,0 +1,78 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Exercises undici response timeouts against a real local server. +import * as NodeHttp from "node:http"; + +import * as Undici from "undici"; +import { describe, expect, it } from "vite-plus/test"; + +import { fetchWithoutResponseTimeouts } from "./OpenCodeRuntime.ts"; + +async function heldServer() { + const held = new Map(); + const waiters = new Map void>(); + const server = NodeHttp.createServer((request, response) => { + held.set(request.url!, response); + waiters.get(request.url!)?.(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + return { + url: (path: string) => `http://127.0.0.1:${address.port}${path}`, + received: (path: string) => + held.has(path) + ? Promise.resolve() + : new Promise((resolve) => waiters.set(path, resolve)), + reply: (path: string) => held.get(path)!.end("true"), + [Symbol.asyncDispose]: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + +function withGlobalDispatcher(dispatcher: Undici.Dispatcher) { + const previous = Undici.getGlobalDispatcher(); + Undici.setGlobalDispatcher(dispatcher); + return { + [Symbol.asyncDispose]: async () => { + Undici.setGlobalDispatcher(previous); + await dispatcher.close(); + }, + }; +} + +describe("fetchWithoutResponseTimeouts", () => { + it("outlives the dispatcher's header timeout and still aborts", async () => { + await using _dispatcher = withGlobalDispatcher(new Undici.Agent({ headersTimeout: 10 })); + await using server = await heldServer(); + + const command = fetchWithoutResponseTimeouts( + new Request(server.url("/command"), { method: "POST", body: "{}" }), + ); + await server.received("/command"); + + // Starts after the command, so the command's header timer would have fired first. + const probe = fetch(server.url("/probe")); + await server.received("/probe"); + const probeError = await probe.then( + () => undefined, + (error: unknown) => error, + ); + expect((probeError as Error).cause).toMatchObject({ code: "UND_ERR_HEADERS_TIMEOUT" }); + + server.reply("/command"); + expect(await (await command).text()).toBe("true"); + + const controller = new AbortController(); + const cancelled = fetchWithoutResponseTimeouts( + new Request(server.url("/cancelled"), { + method: "POST", + body: "{}", + signal: controller.signal, + }), + ); + await server.received("/cancelled"); + controller.abort(); + await expect(cancelled).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index f18ab07bbe19..f196f3cd910b 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -222,6 +222,32 @@ export const loadOpenCodeCommands = (client: OpencodeClient) => ), ); +// undici's Dispatcher: built-in fetch accepts one through the non-standard `dispatcher` init field. +interface FetchDispatcher { + readonly dispatch: (options: object, handler: unknown) => boolean; +} + +// Built-in fetch on undici 6/7 (Node 22/24) reads the v1 global; undici 8 reads v2 and keeps v1 as a wrapper. +const builtinFetchDispatcherKey = Symbol.for( + Number.parseInt(process.versions.undici ?? "", 10) >= 8 + ? "undici.globalDispatcher.2" + : "undici.globalDispatcher.1", +); + +// Reuses the process dispatcher (pools, env proxy) and only drops undici's 300s response timeouts. +const untimedFetchDispatcher: FetchDispatcher = { + dispatch: (options, handler) => + (Reflect.get(globalThis, builtinFetchDispatcherKey) as FetchDispatcher).dispatch( + { ...options, headersTimeout: 0, bodyTimeout: 0 }, + handler, + ), +}; + +/** OpenCode answers `session.command` only when the whole run ends; AbortSignal still cancels it. */ +export const fetchWithoutResponseTimeouts: typeof fetch = (input, init) => + // @effect-diagnostics-next-line globalFetch:off - The OpenCode SDK takes a fetch implementation. + fetch(input, { ...init, dispatcher: untimedFetchDispatcher } as RequestInit); + export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string; diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 7762c3b64503..9e7eff58e647 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2986,6 +2986,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const request = yield* sdkCall("session.command", commandPayload, (signal) => client.session.command(commandPayload, { signal: AbortSignal.any([signal, abortController.signal]), + fetch: OpenCodeRuntime.fetchWithoutResponseTimeouts, }), ).pipe( Effect.asVoid, @@ -2994,9 +2995,15 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function turn.finalized || turn.admissionGeneration !== generation ? Effect.void - : finalizeTurn(state, turn, "failed", { - failure: makeProviderFailure({ cause, class: "provider_error" }), - }), + : Effect.logWarning("OpenCode command request failed.", { + errorTag: cause._tag, + }).pipe( + Effect.andThen( + finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause, class: "provider_error" }), + }), + ), + ), ), Effect.ensuring( Effect.sync(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 381ff78688eb..eee3a05b8f80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1848,6 +1848,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + undici: + specifier: 'catalog:' + version: 8.11.2 vite-plus: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)