From ca933b08b9132913a0317dfd835fd57d76f844c2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 15:50:58 +0000 Subject: [PATCH 1/2] fix(opencode): keep slash-command turns running past 5 minutes OpenCode answers POST /session/:id/command only when the whole command run ends. Node's fetch drops that response after undici's default 300s header timeout, so T3 marked long /command turns failed while OpenCode kept working. The session.command call now uses a fetch whose per-request dispatcher wraps the process's existing global dispatcher and only disables the header and body timeouts. AbortSignal cancellation and every other OpenCode request keep their normal behavior. The command request failure is also logged before the turn is finalized, since the UI message stays curated. Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/package.json | 1 + .../src/server/OpenCodeRuntime.fetch.test.ts | 78 +++++++++++++++++++ .../src/server/OpenCodeRuntime.ts | 26 +++++++ .../provider-opencode/src/server/adapter.ts | 11 ++- pnpm-lock.yaml | 3 + 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 packages/provider-opencode/src/server/OpenCodeRuntime.fetch.test.ts 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..744f7a19a6aa 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,13 @@ 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.", { cause }).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) From e86c63ebceb008a2a49311a3d29d7ed14f1652c6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 16:08:08 +0000 Subject: [PATCH 2/2] fix(opencode): keep SDK error bodies out of warning logs --- packages/provider-opencode/src/server/adapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 744f7a19a6aa..9e7eff58e647 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2995,7 +2995,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function turn.finalized || turn.admissionGeneration !== generation ? Effect.void - : Effect.logWarning("OpenCode command request failed.", { cause }).pipe( + : Effect.logWarning("OpenCode command request failed.", { + errorTag: cause._tag, + }).pipe( Effect.andThen( finalizeTurn(state, turn, "failed", { failure: makeProviderFailure({ cause, class: "provider_error" }),