diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b09..b4a17433788d 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -13,6 +13,8 @@ export function adapterState() { reasoning: 0, currentTextID: undefined as string | undefined, currentReasoningID: undefined as string | undefined, + startedText: {} as Record, + startedReasoning: {} as Record, toolNames: {} as Record, copilotTotalNanoAiu: undefined as number | undefined, } @@ -73,6 +75,18 @@ function currentReasoningID(state: ReturnType, id: string | return state.currentReasoningID } +// AI SDK reports missing stream starts in-band, then continues streaming. +function isOrphanStreamStateError(error: unknown) { + const message = errorMessage(error).trim() + return message.endsWith(" not found") && (message.startsWith("reasoning part ") || message.startsWith("text part ")) +} + +function synthesizeStart(started: Record, id: string, make: () => LLMEvent): LLMEvent[] { + if (started[id]) return [] + started[id] = true + return [make()] +} + export function toLLMEvents( state: ReturnType, event: AISDKEvent, @@ -126,6 +140,7 @@ export function toLLMEvents( case "text-start": return Effect.sync(() => { state.currentTextID = currentTextID(state, event.id) + state.startedText[state.currentTextID] = true return [ LLMEvent.textStart({ id: state.currentTextID, @@ -135,19 +150,28 @@ export function toLLMEvents( }) case "text-delta": - return Effect.succeed([ - LLMEvent.textDelta({ - id: currentTextID(state, event.id), - text: event.text, - providerMetadata: providerMetadata(event.providerMetadata), - }), - ]) + return Effect.sync(() => { + const id = currentTextID(state, event.id) + return [ + ...synthesizeStart(state.startedText, id, () => + LLMEvent.textStart({ id, providerMetadata: providerMetadata(event.providerMetadata) }), + ), + LLMEvent.textDelta({ + id, + text: event.text, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) case "text-end": return Effect.sync(() => { const id = currentTextID(state, event.id) state.currentTextID = undefined return [ + ...synthesizeStart(state.startedText, id, () => + LLMEvent.textStart({ id, providerMetadata: providerMetadata(event.providerMetadata) }), + ), LLMEvent.textEnd({ id, providerMetadata: providerMetadata(event.providerMetadata), @@ -158,6 +182,7 @@ export function toLLMEvents( case "reasoning-start": return Effect.sync(() => { state.currentReasoningID = currentReasoningID(state, event.id) + state.startedReasoning[state.currentReasoningID] = true return [ LLMEvent.reasoningStart({ id: state.currentReasoningID, @@ -167,19 +192,28 @@ export function toLLMEvents( }) case "reasoning-delta": - return Effect.succeed([ - LLMEvent.reasoningDelta({ - id: currentReasoningID(state, event.id), - text: event.text, - providerMetadata: providerMetadata(event.providerMetadata), - }), - ]) + return Effect.sync(() => { + const id = currentReasoningID(state, event.id) + return [ + ...synthesizeStart(state.startedReasoning, id, () => + LLMEvent.reasoningStart({ id, providerMetadata: providerMetadata(event.providerMetadata) }), + ), + LLMEvent.reasoningDelta({ + id, + text: event.text, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) case "reasoning-end": return Effect.sync(() => { const id = currentReasoningID(state, event.id) state.currentReasoningID = undefined return [ + ...synthesizeStart(state.startedReasoning, id, () => + LLMEvent.reasoningStart({ id, providerMetadata: providerMetadata(event.providerMetadata) }), + ), LLMEvent.reasoningEnd({ id, providerMetadata: providerMetadata(event.providerMetadata), @@ -262,6 +296,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/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2bec..d4999b6c33ef 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -313,13 +313,42 @@ describe("session.llm.ai-sdk adapter", () => { ]) expect(events).toMatchObject([ + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "implicit text" }, { type: "text-end", id: "text-0" }, + { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" }, { type: "reasoning-end", id: "reasoning-0" }, ]) }) + test("repairs orphan stream parts without hiding provider errors", async () => { + const metadata = { openai: { itemID: "rs_abc" } } + const events = await adapt([ + uncheckedAdapterEvent({ + type: "reasoning-delta", + id: "rs_abc:0", + text: "thinking", + providerMetadata: metadata, + }), + uncheckedAdapterEvent({ type: "reasoning-end", id: "rs_abc:0" }), + uncheckedAdapterEvent({ type: "error", error: "reasoning part rs_abc:0 not found" }), + ]) + + expect(events).toMatchObject([ + { type: "reasoning-start", id: "rs_abc:0", providerMetadata: metadata }, + { type: "reasoning-delta", id: "rs_abc:0", text: "thinking", providerMetadata: metadata }, + { type: "reasoning-end", id: "rs_abc:0" }, + ]) + const failure = await Effect.runPromiseExit( + LLMAISDK.toLLMEvents( + LLMAISDK.adapterState(), + uncheckedAdapterEvent({ type: "error", error: "rate limit exceeded" }), + ), + ) + expect(Exit.isFailure(failure)).toBe(true) + }) + test("explicitly ignores non-session-visible AI SDK chunks", async () => { expect( await adapt([ @@ -441,8 +470,10 @@ describe("session.llm.ai-sdk adapter", () => { expect(secondStream).toMatchObject([ { type: "step-start", index: 0 }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "second" }, { type: "text-end", id: "text-0" }, + { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" }, { type: "reasoning-end", id: "reasoning-0" }, ])