Skip to content

Commit 1fdb393

Browse files
committed
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.
1 parent b94c6ac commit 1fdb393

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1108,9 +1108,22 @@ const layer = Layer.effect(
11081108
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
11091109
) ?? false
11101110

1111+
// "unknown" is every mapper's fallback for a finish reason we could not
1112+
// interpret, and the AI SDK reports a stream that closed without any
1113+
// finish chunk at all as "other", which maps here too. Neither means the
1114+
// model was done, so resample the turn like "tool-calls" instead of
1115+
// exiting as a clean completion. Exiting here silently truncated runs
1116+
// mid-task: the error check below already excludes "unknown", so nothing
1117+
// was recorded anywhere.
1118+
if (lastAssistant?.finish === "unknown")
1119+
yield* Effect.logWarning("resampling turn that ended with an unmapped finish reason", {
1120+
"session.id": sessionID,
1121+
messageID: lastAssistant.id,
1122+
})
1123+
11111124
if (
11121125
lastAssistant?.finish &&
1113-
!["tool-calls"].includes(lastAssistant.finish) &&
1126+
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
11141127
!hasToolCalls &&
11151128
lastUser.id < lastAssistant.id
11161129
) {

packages/opencode/test/session/prompt.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,36 @@ it.instance("loop continues when finish is tool-calls", () =>
810810
}),
811811
)
812812

813+
it.instance("loop resamples when the provider closes a stream without a finish reason", () =>
814+
Effect.gen(function* () {
815+
const { llm } = yield* useServerConfig(providerCfg)
816+
const prompt = yield* SessionPrompt.Service
817+
const sessions = yield* Session.Service
818+
const session = yield* sessions.create({
819+
title: "Pinned",
820+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
821+
})
822+
yield* prompt.prompt({
823+
sessionID: session.id,
824+
agent: "build",
825+
noReply: true,
826+
parts: [{ type: "text", text: "hello" }],
827+
})
828+
// No finish chunk: the AI SDK reports this as finishReason "other", which we
829+
// map to "unknown". The loop must resample instead of ending the run.
830+
yield* llm.push(reply().item())
831+
yield* llm.text("second")
832+
833+
const result = yield* prompt.loop({ sessionID: session.id })
834+
expect(yield* llm.calls).toBe(2)
835+
expect(result.info.role).toBe("assistant")
836+
if (result.info.role === "assistant") {
837+
expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
838+
expect(result.info.finish).toBe("stop")
839+
}
840+
}),
841+
)
842+
813843
it.instance("glob tool keeps instance context during prompt runs", () =>
814844
Effect.gen(function* () {
815845
const { dir, llm } = yield* useServerConfig(providerCfg)

0 commit comments

Comments
 (0)