Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/opencode/src/session/llm/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ function currentReasoningID(state: ReturnType<typeof adapterState>, 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<typeof adapterState>,
event: AISDKEvent,
Expand Down Expand Up @@ -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<LLMEvent[]>([]))
return Effect.fail(event.error)

case "abort":
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/session/ai-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof LLMAISDK.toLLMEvents>[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)
}
})
})
Loading