diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b09..8202428b096f 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -73,6 +73,18 @@ function currentReasoningID(state: ReturnType, id: string | return state.currentReasoningID } +// The AI SDK enqueues a non-fatal in-band error part +// { type: "error", error: `${"reasoning" | "text"} part ${id} not found` } +// when a reasoning/text delta (or its end) arrives with no preceding *-start +// block. This is common with OpenAI-compatible and Anthropic proxies that omit +// the start events. The SDK returns and keeps streaming, so this must not be +// promoted to a fatal turn failure. Verified against vercel/ai@6 stream-text.ts +// (text part: L1171/L1190, reasoning part: L1220/L1239). +function isOrphanStreamStateError(error: unknown) { + const message = errorMessage(error).trim() + return message.endsWith(" not found") && (message.startsWith("reasoning part ") || message.startsWith("text part ")) +} + export function toLLMEvents( state: ReturnType, event: AISDKEvent, @@ -262,6 +274,10 @@ export function toLLMEvents( }) case "error": + if (isOrphanStreamStateError(event.error)) + return Effect.logDebug("dropping orphan reasoning/text stream part", { + detail: errorMessage(event.error), + }).pipe(Effect.as([])) return Effect.fail(event.error) case "abort": diff --git a/packages/opencode/test/session/ai-sdk.test.ts b/packages/opencode/test/session/ai-sdk.test.ts new file mode 100644 index 000000000000..9e116f0896b2 --- /dev/null +++ b/packages/opencode/test/session/ai-sdk.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { LLMAISDK } from "@/session/llm/ai-sdk" + +type ErrorEvent = Extract[1], { type: "error" }> + +const errorEvent = (error: unknown): ErrorEvent => ({ type: "error", error }) + +const run = (error: unknown) => Effect.runPromiseExit(LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), errorEvent(error))) + +describe("LLMAISDK.toLLMEvents error handling", () => { + test("drops orphan reasoning/text stream-state errors without failing the turn", async () => { + // vercel/ai stream-text.ts enqueues these exact strings as non-fatal in-band + // error parts when a reasoning/text delta (or its end) has no preceding + // *-start block. They must not abort the assistant turn. + for (const message of [ + "reasoning part 0 not found", + "reasoning part 7 not found", + "reasoning part rs_abc:0 not found", + "text part 0 not found", + "text part X not found", + ]) { + const exit = await run(message) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toEqual([]) + } + }) + + test("tolerates surrounding whitespace via trim", async () => { + const exit = await run(" reasoning part 0 not found ") + expect(Exit.isSuccess(exit)).toBe(true) + }) + + test("still fails on genuine provider errors", async () => { + for (const error of ["rate limit exceeded", 'Tool "foo" not found', "context length exceeded", new Error("boom")]) { + const exit = await run(error) + expect(Exit.isFailure(exit)).toBe(true) + } + }) +})