From 27e6cd07477497a0a1683d9fc32e7dcca2623ba5 Mon Sep 17 00:00:00 2001 From: DRIV Date: Wed, 29 Jul 2026 10:30:35 +0200 Subject: [PATCH] fix(opencode): retry truncated provider streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [OpenCode](https://opencode.ai) (GPT-5) --- packages/opencode/src/session/llm/ai-sdk.ts | 4 ++ packages/opencode/src/tool/task.ts | 2 + packages/opencode/test/session/llm.test.ts | 27 ++++++++++ packages/opencode/test/session/prompt.test.ts | 25 ++++++++++ packages/opencode/test/tool/task.test.ts | 49 +++++++++++++++++-- 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b09..6ef52b77816f 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -1,6 +1,7 @@ import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { type streamText } from "ai" +import { ProviderError } from "@/provider/error" import { errorMessage } from "@/util/error" type Result = Awaited> @@ -86,6 +87,9 @@ export function toLLMEvents( case "finish-step": return Effect.sync(() => { + if (event.finishReason === "other" && event.rawFinishReason === undefined) { + throw new ProviderError.ResponseStreamError("Provider stream ended without a finish reason") + } const original = providerMetadata(event.providerMetadata) const metadata = state.copilotTotalNanoAiu === undefined diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d19725..84a65ca15df8 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -210,6 +210,8 @@ export const TaskTool = Tool.define( agent: next.name, parts, }) + if (result.info.role === "assistant" && result.info.error) + return yield* Effect.fail(new Error(result.info.error.name)) return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2bec..9114484f2eb4 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -385,6 +385,33 @@ describe("session.llm.ai-sdk adapter", () => { expect(stepFinish.usage).toBeUndefined() }) + test("fails a synthesized finish without a provider finish reason", async () => { + const exit = await Effect.runPromise( + LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), { + type: "finish-step", + response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" }, + finishReason: "other", + rawFinishReason: undefined, + providerMetadata: undefined, + usage: { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + }).pipe(Effect.exit), + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) throw new Error("expected provider stream error") + const error = Cause.squash(exit.cause) + expect(error).toMatchObject({ + name: "ProviderResponseStreamError", + message: "Provider stream ended without a finish reason", + }) + }) + test("reuses adapter state cleanly across streams once finish has fired", async () => { // adapterState() is meant to be per-stream, but the only thing finish currently clears // is toolNames — step, text counters, and the current text/reasoning IDs all leak diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..91de4dee4285 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2203,6 +2203,31 @@ it.instance("does not loop empty assistant turns for a simple reply", () => }), ) +it.instance("retries a provider stream that ends 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: "Truncated stream" }) + + yield* llm.push(reply().reason("partial reasoning"), reply().text("recovered").stop()) + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Respond after retrying" }], + }) + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("stop") + expect(result.info.error).toBeUndefined() + } + expect(result.parts.some((part) => part.type === "text" && part.text === "recovered")).toBe(true) + expect(yield* llm.calls).toBe(2) + }), +) + it.instance("records aborted errors when prompt is cancelled mid-stream", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a49b..b938dcf778ae 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -96,19 +96,27 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { return { chat, assistant } }) -function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { +function stubOps(opts?: { + onPrompt?: (input: SessionPrompt.PromptInput) => void + text?: string + error?: NonNullable +}): TaskPromptOps { return { cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done") + return reply(input, opts?.text ?? "done", opts?.error) }), } } -function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts { +function reply( + input: SessionPrompt.PromptInput, + text: string, + error?: NonNullable, +): SessionV1.WithParts { const id = MessageID.ascending() return { info: { @@ -125,6 +133,7 @@ function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithPa providerID: input.model?.providerID ?? ref.providerID, time: { created: Date.now() }, finish: "stop", + error, }, parts: [ { @@ -181,6 +190,40 @@ describe("tool.task", () => { }, ) + it.instance("execute fails when the child session returns an error", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: stubOps({ + error: new SessionV1.APIError({ message: "Provider stream failed", isRetryable: true }).toObject(), + }), + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + it.instance( "description hides denied subagents for the caller", () =>