From 5c061d545b96bb69ea980e9ac91f30ac95aa79cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:43:14 +0000 Subject: [PATCH 1/2] test(opencode): reproduce admitted command failing on HTTP timeout (#27664) These tests reproduce #27664: once a native OpenCode command is admitted by its user receipt, a later session.command request failure (Node's undici headers timeout after 300 s) still finalizes the turn as failed instead of letting session.idle complete it. Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 52f7590e63f42..e8bec5744dec1 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1338,6 +1338,86 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("keeps an admitted native command running when its HTTP request times out", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const commandCalled = promiseGate(); + let rejectCommand!: (cause: unknown) => void; + const commandResponse = new Promise((_, reject) => { + rejectCommand = reject; + }); + const sessionId = "native-command-timeout"; + const harness = yield* makeOpenCodeRuntimeHarness("native-command-timeout", sessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + command: (input: { messageID: string }) => { + commandCalled.resolve(input.messageID); + return commandResponse; + }, + }, + }); + const start = yield* harness.startTurn("/review staged changes").pipe(Effect.forkScoped); + const messageID = yield* Effect.promise(() => commandCalled.promise); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: messageID, + sessionID: sessionId, + role: "user", + time: { created: DateTime.toEpochMillis(harness.now) }, + }, + }, + }), + ); + yield* Fiber.join(start); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + // Node's fetch gives up on a command still running in OpenCode after 300 s. + rejectCommand( + new TypeError("fetch failed", { + cause: Object.assign(new Error("Headers Timeout Error"), { + name: "HeadersTimeoutError", + code: "UND_ERR_HEADERS_TIMEOUT", + }), + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.status", + properties: { sessionID: sessionId, status: { type: "busy" } }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: sessionId } }), + ); + + const [event] = yield* Fiber.join(terminal); + assert.equal( + event?.type === "turn.terminal" ? event.status : undefined, + "completed", + "a command admitted by its user receipt must not fail when its HTTP request times out", + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("compacts with the native summarize API and emits a completed compaction", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); From 157d5247f8aed3fb74628b3b9799ce3d0655575b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:49:05 +0000 Subject: [PATCH 2/2] fix(opencode): long command turns no longer fail when the command request times out OpenCode's POST /session/{id}/command only responds once the command run finishes, so Node's fetch drops it after its 300 s headers timeout while OpenCode keeps working. The request's failure handler then failed the turn even though the command had already been admitted by its user receipt. Once the receipt has admitted the command, a failed command request no longer finalizes the turn; session.idle / session.error decide the outcome, as they do for promptAsync turns. Failures before admission still fail it. Fixes pingdotgg/t3code#27664 Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/src/server/adapter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 7762c3b64503d..78c179bf010fe 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2989,7 +2989,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }), ).pipe( Effect.asVoid, + // OpenCode answers only when generation ends; once admitted, the event stream owns the outcome. Effect.tapError((cause) => + Deferred.isDoneUnsafe(receipt) || abortController.signal.aborted || turn.finalized || turn.admissionGeneration !== generation