diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b9..5182a706c 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 491ad06aa..2f815199b 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)