From 1fdb39372226a94869109af49ce2c2c11fe2705e Mon Sep 17 00:00:00 2001 From: Alexander Yue Date: Sat, 22 Aug 2026 15:14:35 -0700 Subject: [PATCH] fix(opencode): resample turns with an unmapped finish reason A provider stream that opens, delivers a first chunk, then dies without sending a finish chunk is reported by the AI SDK as finishReason "other" with null usage. "other" is not in the FinishReason literal set, so session/llm/ai-sdk.ts maps it to "unknown". The two loop guards then disagreed. The error check at prompt.ts:1295 excludes "unknown", so no error was recorded. The loop-exit check did not, so the loop broke. The turn exited cleanly mid-task with no error, no timeout and no errored span, and the run was indistinguishable from a model that gave up. Measured on the Odysseys benchmark: 0.054% of LLM calls, ~5.3% of tasks (11/200 and 12/200 on two full runs), and 100% of them terminal. "unknown" is the fallback in every mapper (openai-chat, openai-responses, anthropic-messages, gemini, bedrock-converse) and never denotes a normal completion, so exclude it from the loop-exit check as well and let the turn resample. The dead turn contributes no model messages, so the repeat request is identical to the one that was dropped. A warning log keeps the event visible when the resample succeeds. --- packages/opencode/src/session/prompt.ts | 15 +++++++++- packages/opencode/test/session/prompt.test.ts | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b96..5182a706c0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1108,9 +1108,22 @@ const layer = Layer.effect( (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part), ) ?? false + // "unknown" is every mapper's fallback for a finish reason we could not + // interpret, and the AI SDK reports a stream that closed without any + // finish chunk at all as "other", which maps here too. Neither means the + // model was done, so resample the turn like "tool-calls" instead of + // exiting as a clean completion. Exiting here silently truncated runs + // mid-task: the error check below already excludes "unknown", so nothing + // was recorded anywhere. + if (lastAssistant?.finish === "unknown") + yield* Effect.logWarning("resampling turn that ended with an unmapped finish reason", { + "session.id": sessionID, + messageID: lastAssistant.id, + }) + if ( lastAssistant?.finish && - !["tool-calls"].includes(lastAssistant.finish) && + !["tool-calls", "unknown"].includes(lastAssistant.finish) && !hasToolCalls && lastUser.id < lastAssistant.id ) { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf..2f815199be 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -810,6 +810,36 @@ it.instance("loop continues when finish is tool-calls", () => }), ) +it.instance("loop resamples when the provider closes a stream without a finish reason", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + // No finish chunk: the AI SDK reports this as finishReason "other", which we + // map to "unknown". The loop must resample instead of ending the run. + yield* llm.push(reply().item()) + yield* llm.text("second") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(yield* llm.calls).toBe(2) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } + }), +) + it.instance("glob tool keeps instance context during prompt runs", () => Effect.gen(function* () { const { dir, llm } = yield* useServerConfig(providerCfg)