From 87dd316b3fdd8abe2b11b4e4d6594aedc7fba818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=A0=E5=BA=B7Kk?= <1837620622@qq.com> Date: Mon, 14 Sep 2026 11:44:38 +0800 Subject: [PATCH] fix(session): recover stale encrypted reasoning Retry once after stripping caller-bound reasoning.encrypted_content when the provider rejects it, matching overflow recovery. --- packages/core/src/session/runner/llm.ts | 33 ++++- packages/core/src/session/stale-reasoning.ts | 54 +++++++ packages/core/test/session-runner.test.ts | 138 ++++++++++++++++++ packages/llm/src/index.ts | 2 +- .../llm/src/protocols/openai-responses.ts | 9 +- packages/llm/src/provider-error.ts | 29 ++++ packages/llm/src/route/executor.ts | 8 +- packages/llm/src/schema/errors.ts | 2 +- packages/llm/test/provider-error.test.ts | 37 ++++- packages/opencode/src/provider/error.ts | 25 +++- packages/opencode/src/session/message-v2.ts | 20 +++ packages/opencode/src/session/processor.ts | 35 ++++- .../opencode/src/session/stale-reasoning.ts | 75 ++++++++++ packages/opencode/test/provider/error.test.ts | 76 ++++++++++ .../opencode/test/session/message-v2.test.ts | 20 +++ 15 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/session/stale-reasoning.ts create mode 100644 packages/opencode/src/session/stale-reasoning.ts diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 874086a06bdb..6dfe256817b2 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -6,6 +6,7 @@ import { Message, SystemPart, isContextOverflowFailure, + isStaleReasoningFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" @@ -30,6 +31,7 @@ import { SessionEvent } from "../event" import { SessionHistory } from "../history" import { SessionInput } from "../input" import { SessionSchema } from "../schema" +import { SessionStaleReasoning } from "../stale-reasoning" import { SessionStore } from "../store" import { type RunError, Service } from "./index" import { SessionRunnerModel } from "./model" @@ -154,6 +156,8 @@ const layer = Layer.effect( | { readonly _tag: "ContinueAfterCompaction"; readonly step: number } // Overflow compaction completed; rebuild once through the path without overflow recovery. | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number } + // Stale encrypted reasoning was stripped; rebuild once without those caller-bound blobs. + | { readonly _tag: "ContinueAfterStaleReasoning"; readonly step: number } class TurnTransitionError extends Error { constructor(readonly transition: TurnTransition) { @@ -164,6 +168,8 @@ const layer = Layer.effect( const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step }) const continueAfterOverflowCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) + const continueAfterStaleReasoning = (step: number) => + new TurnTransitionError({ _tag: "ContinueAfterStaleReasoning", step }) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { @@ -175,6 +181,7 @@ const layer = Layer.effect( promotion: SessionInput.Delivery | undefined, step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, + recoverStaleReasoning = true, ) { const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) @@ -236,15 +243,20 @@ const layer = Layer.effect( const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined + let staleReasoningFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { - if (overflowFailure || publisher.hasProviderError()) return + if (overflowFailure || staleReasoningFailure || publisher.hasProviderError()) return if (LLMEvent.is.providerError(event)) { if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) { overflowFailure = event return } + if (recoverStaleReasoning && isStaleReasoningFailure(event) && !publisher.hasAssistantStarted()) { + staleReasoningFailure = event + return + } } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return @@ -293,7 +305,16 @@ const layer = Layer.effect( (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) ) return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) + if ( + recoverStaleReasoning && + !publisher.hasAssistantStarted() && + isStaleReasoningFailure(staleReasoningFailure ?? failure) + ) { + yield* restore(SessionStaleReasoning.persist(db, session.id, context)) + return yield* Effect.die(continueAfterStaleReasoning(currentStep)) + } if (overflowFailure) yield* publish(overflowFailure) + if (staleReasoningFailure) yield* publish(staleReasoningFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) @@ -366,6 +387,8 @@ const layer = Layer.effect( if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") + if (defect.transition._tag === "ContinueAfterStaleReasoning") + return yield* runTurnAttempt(sessionID, undefined, defect.transition.step, undefined, false) yield* Effect.yieldNow return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) }), @@ -381,6 +404,14 @@ const layer = Layer.effect( yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) + if (defect.transition._tag === "ContinueAfterStaleReasoning") + return yield* runTurnAttempt( + sessionID, + undefined, + defect.transition.step, + compaction.compactAfterOverflow, + false, + ) return yield* runTurn(sessionID, undefined, defect.transition.step) }), ), diff --git a/packages/core/src/session/stale-reasoning.ts b/packages/core/src/session/stale-reasoning.ts new file mode 100644 index 000000000000..58af1c535605 --- /dev/null +++ b/packages/core/src/session/stale-reasoning.ts @@ -0,0 +1,54 @@ +import { and, eq } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { Database } from "../database/database" +import { SessionMessage } from "./message" +import type { SessionSchema } from "./schema" +import { SessionMessageTable } from "./sql" + +const encodeMessage = Schema.encodeSync(SessionMessage.Message) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const stripOpenaiReplay = (metadata: Record | undefined) => { + if (!metadata) return { metadata, changed: false } + const openai = metadata.openai + if (!isRecord(openai)) return { metadata, changed: false } + if (!("reasoningEncryptedContent" in openai) && !("itemId" in openai)) return { metadata, changed: false } + const nextOpenai = { ...openai } + delete nextOpenai.reasoningEncryptedContent + delete nextOpenai.itemId + const next = { ...metadata } + if (Object.keys(nextOpenai).length === 0) delete next.openai + else next.openai = nextOpenai + return { metadata: Object.keys(next).length === 0 ? undefined : next, changed: true } +} + +export const persist = Effect.fn("SessionStaleReasoning.persist")(function* ( + db: Database.Interface["db"], + sessionID: SessionSchema.ID, + messages: readonly SessionMessage.Message[], +) { + for (const message of messages) { + if (message.type !== "assistant") continue + let changed = false + const content = message.content.map((item) => { + if (item.type !== "reasoning") return item + const stripped = stripOpenaiReplay(item.providerMetadata as Record | undefined) + if (!stripped.changed) return item + changed = true + return { ...item, providerMetadata: stripped.metadata } + }) + if (!changed) continue + const encoded = encodeMessage({ ...message, content }) + const data = Object.fromEntries(Object.entries(encoded).filter(([key]) => key !== "id" && key !== "type")) + yield* db + .update(SessionMessageTable) + .set({ data: data as typeof SessionMessageTable.$inferInsert.data }) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.id, message.id))) + .run() + .pipe(Effect.orDie) + } +}) + +export * as SessionStaleReasoning from "./stale-reasoning" diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cc58b43b2957..7b001316bad5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1289,6 +1289,112 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("strips stale encrypted reasoning and retries once", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) + requests.length = 0 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.reasoningStart({ + id: "reasoning-openai", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }), + LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-openai", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + yield* session.resume(sessionID) + + requests.length = 0 + responses = [ + [ + LLMEvent.providerError({ + message: "reasoning `encrypted_content` was not issued to this caller", + classification: "stale-reasoning", + }), + ], + fragmentFixture("text", "text-recovered", ["Recovered"]).completeEvents, + ] + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.messages[1]?.content).toEqual([ + { + type: "reasoning", + text: "Encrypted thought", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + ]) + expect(requests[1]?.messages[1]?.content).toEqual([{ type: "reasoning", text: "Encrypted thought" }]) + const stored = yield* session.context(sessionID) + const firstAssistant = stored.find((message) => message.type === "assistant") + expect(firstAssistant).toMatchObject({ + type: "assistant", + content: [{ type: "reasoning", text: "Encrypted thought" }], + }) + if (firstAssistant?.type === "assistant") { + const reasoning = firstAssistant.content.find((item) => item.type === "reasoning") + expect(reasoning && "providerMetadata" in reasoning ? reasoning.providerMetadata?.openai : undefined).toBeUndefined() + } + expect(stored.slice(-1)).toMatchObject([{ type: "assistant", finish: "stop" }]) + }), + ) + + it.effect("recovers once from a raw stale encrypted reasoning failure", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) + requests.length = 0 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.reasoningStart({ + id: "reasoning-openai", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }), + LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-openai", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + yield* session.resume(sessionID) + + requests.length = 0 + responseStream = Stream.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ + message: + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + }), + }), + ) + responses = [fragmentFixture("text", "text-recovered", ["Recovered"]).completeEvents] + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[1]?.messages[1]?.content).toEqual([{ type: "reasoning", text: "Encrypted thought" }]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Think first" }, + { type: "assistant" }, + { type: "user", text: "Continue" }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + it.effect("publishes the original overflow when recovery summarization fails", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery @@ -3193,6 +3299,38 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not recover stale encrypted reasoning after durable assistant output", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false }) + + requests.length = 0 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-partial" }), + LLMEvent.textDelta({ id: "text-partial", text: "Partial" }), + LLMEvent.textEnd({ id: "text-partial" }), + LLMEvent.providerError({ + message: "reasoning `encrypted_content` was not issued to this caller", + classification: "stale-reasoning", + }), + ] + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Fail after output" }, + { + type: "assistant", + finish: "error", + error: { message: "reasoning `encrypted_content` was not issued to this caller" }, + content: [{ type: "text", text: "Partial" }], + }, + ]) + }), + ) + it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { yield* setup diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77c2..81a55a8cb84c 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,7 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" -export { isContextOverflow, isContextOverflowFailure } from "./provider-error" +export { isContextOverflow, isContextOverflowFailure, isStaleReasoning, isStaleReasoningFailure } from "./provider-error" export type { RouteModelInput, RouteRoutedModelInput, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c921b..c07a364e69b5 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -19,7 +19,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" -import { isContextOverflow } from "../provider-error" +import { isContextOverflow, isStaleReasoning } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" import { ToolSchemaProjection } from "./utils/tool-schema" @@ -906,7 +906,12 @@ const providerError = (event: OpenAIResponsesEvent, fallback: string) => { const message = providerErrorMessage(event, fallback) return LLMEvent.providerError({ message, - classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined, + classification: + code === "invalid_encrypted_content" || isStaleReasoning(message) + ? "stale-reasoning" + : code === "context_length_exceeded" || isContextOverflow(message) + ? "context-overflow" + : undefined, }) } diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index f8b8a5c013e1..263812048f5f 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -33,11 +33,40 @@ const patterns = [ const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] +const staleReasoningPatterns = [ + /reasoning.*encrypted_content.*not issued to this caller/i, + /encrypted_content.*was not issued to this caller/i, + /invalid_encrypted_content/i, + /encrypted content could not be (verified|decrypted|parsed)/i, + /referenced reasoning item .* (was not found|has expired)/i, + /item .* of type ['"]reasoning['"] was provided without its required following item/i, +] + export const isContextOverflow = (message: string) => !exclusions.some((pattern) => pattern.test(message)) && (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) +export const isStaleReasoning = (message: string) => staleReasoningPatterns.some((pattern) => pattern.test(message)) + export const isContextOverflowFailure = (failure: unknown) => failure instanceof LLMError ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" + +const failureText = (failure: unknown) => { + if (failure instanceof LLMError) return `${failure.message}\n${failure.reason.message}` + if (Schema.is(ProviderErrorEvent)(failure)) return failure.message + if (failure instanceof Error) return failure.message + return "" +} + +export const isStaleReasoningFailure = (failure: unknown) => { + if ( + failure instanceof LLMError && + failure.reason._tag === "InvalidRequest" && + failure.reason.classification === "stale-reasoning" + ) + return true + if (Schema.is(ProviderErrorEvent)(failure) && failure.classification === "stale-reasoning") return true + return isStaleReasoning(failureText(failure)) +} diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index b2f679c68354..a057f44a434b 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -22,7 +22,7 @@ import { TransportReason, UnknownProviderReason, } from "../schema" -import { isContextOverflow } from "../provider-error" +import { isContextOverflow, isStaleReasoning } from "../provider-error" export interface Interface { readonly execute: ( @@ -259,7 +259,11 @@ const statusReason = (input: { ) { return new InvalidRequestReason({ message: input.message, - classification: isContextOverflow(body) ? "context-overflow" : undefined, + classification: isStaleReasoning(body) + ? "stale-reasoning" + : isContextOverflow(body) + ? "context-overflow" + : undefined, http: input.http, }) } diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 072e4e838928..dd3655cc463f 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" -export const ProviderFailureClassification = Schema.Literal("context-overflow") +export const ProviderFailureClassification = Schema.Literals(["context-overflow", "stale-reasoning"]) export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts index c4185969640a..80080a4d0eab 100644 --- a/packages/llm/test/provider-error.test.ts +++ b/packages/llm/test/provider-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isContextOverflow } from "../src" +import { InvalidRequestReason, LLMError, isContextOverflow, isStaleReasoning, isStaleReasoningFailure } from "../src" describe("provider error classification", () => { test("classifies provider token limit messages as context overflow", () => { @@ -27,4 +27,39 @@ describe("provider error classification", () => { expect(messages.some(isContextOverflow)).toBe(false) }) + + test("classifies stale encrypted reasoning errors", () => { + const messages = [ + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + "Upstream request failed: [invalid_encrypted_content] The encrypted content could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + "Referenced reasoning item 'rs_123' was not found or has expired", + "Item 'rs_0a1b' of type 'reasoning' was provided without its required following item.", + ] + + expect(messages.every(isStaleReasoning)).toBe(true) + expect(isStaleReasoning("prompt is too long")).toBe(false) + }) + + test("matches stale reasoning failures from classified and raw errors", () => { + expect( + isStaleReasoningFailure( + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ + message: "bad request", + classification: "stale-reasoning", + }), + }), + ), + ).toBe(true) + expect( + isStaleReasoningFailure( + new Error( + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + ), + ), + ).toBe(true) + expect(isStaleReasoningFailure(new Error("prompt is too long"))).toBe(false) + }) }) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index cebd08145d21..9a2e02cfb2e7 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,7 +2,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderV2 } from "@opencode-ai/core/provider" -import { isContextOverflow } from "@opencode-ai/llm" +import { isContextOverflow, isStaleReasoning } from "@opencode-ai/llm" export class HeaderTimeoutError extends Error { public override readonly name = "ProviderHeaderTimeoutError" @@ -92,6 +92,11 @@ export type ParsedStreamError = message: string responseBody: string } + | { + type: "stale_reasoning" + message: string + responseBody: string + } | { type: "api_error" message: string @@ -114,6 +119,12 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined message: "Input exceeds context window of this model", responseBody, } + case "invalid_encrypted_content": + return { + type: "stale_reasoning", + message: typeof body?.error?.message === "string" ? body.error.message : "Encrypted reasoning is no longer valid", + responseBody, + } case "insufficient_quota": return { type: "api_error", @@ -159,6 +170,11 @@ export type ParsedAPICallError = message: string responseBody?: string } + | { + type: "stale_reasoning" + message: string + responseBody?: string + } | { type: "api_error" message: string @@ -179,6 +195,13 @@ export function parseAPICallError(input: { providerID: ProviderV2.ID; error: API responseBody: input.error.responseBody, } } + if (isStaleReasoning(m) || body?.error?.code === "invalid_encrypted_content") { + return { + type: "stale_reasoning", + message: m, + responseBody: input.error.responseBody, + } + } const metadata = input.error.url ? { url: input.error.url } : undefined return { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 9b3f2c46f405..e23c87e3d1f3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -690,6 +690,16 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "stale_reasoning") { + return new APIError( + { + message: parsed.message, + isRetryable: false, + responseBody: parsed.responseBody, + }, + { cause: e }, + ).toObject() + } return new APIError( { @@ -717,6 +727,16 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "stale_reasoning") { + return new APIError( + { + message: parsed.message, + isRetryable: false, + responseBody: parsed.responseBody, + }, + { cause: e }, + ).toObject() + } return new APIError( { message: parsed.message, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9f8530929c15..5adc336c24bc 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -24,7 +24,8 @@ import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { Usage, type LLMEvent } from "@opencode-ai/llm" +import { Usage, isStaleReasoningFailure, type LLMEvent } from "@opencode-ai/llm" +import { SessionStaleReasoning } from "./stale-reasoning" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" @@ -72,6 +73,7 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + recoveredStaleReasoning: boolean } type StreamEvent = LLMEvent @@ -111,6 +113,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + recoveredStaleReasoning: false, } let aborted = false @@ -638,6 +641,28 @@ const layer = Layer.effect( yield* status.set(ctx.sessionID, { type: "idle" }) }) + const outputStarted = () => + ctx.currentText !== undefined || Object.keys(ctx.reasoningMap).length > 0 || Object.keys(ctx.toolcalls).length > 0 + + const recoverStaleReasoning = Effect.fn("SessionProcessor.recoverStaleReasoning")(function* ( + streamInput: LLM.StreamInput, + ) { + SessionStaleReasoning.stripRequest(streamInput.messages) + yield* SessionStaleReasoning.persist(session, ctx.sessionID) + yield* Effect.logInfo("recovered stale encrypted reasoning", { + "session.id": ctx.sessionID, + messageID: ctx.assistantMessage.id, + }) + ctx.currentText = undefined + ctx.reasoningMap = {} + yield* status.set(ctx.sessionID, { type: "busy" }) + yield* llm.stream(streamInput).pipe( + Stream.tap((event) => handleEvent(event)), + Stream.takeUntil(() => ctx.needsCompaction), + Stream.runDrain, + ) + }) + const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { yield* Effect.logInfo("process", { "session.id": input.sessionID, @@ -686,6 +711,14 @@ const layer = Layer.effect( }, }), ), + Effect.catchIf( + (error) => !ctx.recoveredStaleReasoning && !outputStarted() && isStaleReasoningFailure(error), + () => + Effect.gen(function* () { + ctx.recoveredStaleReasoning = true + yield* recoverStaleReasoning(streamInput) + }).pipe(Effect.catch(halt)), + ), Effect.catch(halt), Effect.ensuring(cleanup()), ) diff --git a/packages/opencode/src/session/stale-reasoning.ts b/packages/opencode/src/session/stale-reasoning.ts new file mode 100644 index 000000000000..19fb5d88c672 --- /dev/null +++ b/packages/opencode/src/session/stale-reasoning.ts @@ -0,0 +1,75 @@ +import { Effect } from "effect" +import type { ModelMessage } from "ai" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { Session } from "./session" +import type { SessionID } from "./schema" + +const CRYPTO_KEYS = ["reasoningEncryptedContent", "encrypted_content", "encryptedContent", "itemId"] as const + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const stripBox = (value: unknown) => { + if (!isRecord(value)) return false + let changed = false + for (const key of CRYPTO_KEYS) { + if (key in value) { + delete value[key] + changed = true + } + } + if (isRecord(value.openai)) { + if (stripBox(value.openai)) changed = true + if (Object.keys(value.openai).length === 0) delete value.openai + } + return changed +} + +export function stripRequest(messages: ModelMessage[]) { + for (const message of messages) { + if (message.role !== "assistant") continue + if (!Array.isArray(message.content)) continue + for (const part of message.content) { + if (!part || typeof part !== "object") continue + if (!("type" in part) || part.type !== "reasoning") continue + const rec = part as Record + if (typeof rec.id === "string" && rec.id.startsWith("rs_")) delete rec.id + stripBox(rec) + stripBox(rec.providerOptions) + stripBox(rec.providerMetadata) + if (isRecord(rec.providerOptions) && Object.keys(rec.providerOptions).length === 0) delete rec.providerOptions + if (isRecord(rec.providerMetadata) && Object.keys(rec.providerMetadata).length === 0) delete rec.providerMetadata + } + } +} + +export function stripPart(part: SessionV1.ReasoningPart) { + const metadata = part.metadata + if (!isRecord(metadata) || !isRecord(metadata.openai)) return part + const openai = { ...metadata.openai } + if (!("reasoningEncryptedContent" in openai) && !("itemId" in openai)) return part + delete openai.reasoningEncryptedContent + delete openai.itemId + const next = { ...metadata } + if (Object.keys(openai).length === 0) delete next.openai + else next.openai = openai + return { + ...part, + metadata: Object.keys(next).length === 0 ? undefined : next, + } +} + +export const persist = (session: Session.Interface, sessionID: SessionID) => + Effect.gen(function* () { + const msgs = yield* session.messages({ sessionID }) + for (const msg of msgs) { + for (const part of msg.parts) { + if (part.type !== "reasoning") continue + const next = stripPart(part) + if (next === part) continue + yield* session.updatePart(next) + } + } + }) + +export * as SessionStaleReasoning from "./stale-reasoning" diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 49db2806623d..2cacb9b03836 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" +import { APICallError } from "ai" +import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderError } from "@/provider/error" +import { SessionStaleReasoning } from "@/session/stale-reasoning" +import type { SessionV1 } from "@opencode-ai/core/v1/session" describe("provider stream errors", () => { test("retries provider stream errors without a code", () => { @@ -21,4 +25,76 @@ describe("provider stream errors", () => { responseBody: JSON.stringify({ type: "error", error: { message } }), }) }) + + test("classifies invalid encrypted reasoning as stale_reasoning", () => { + expect( + ProviderError.parseStreamError({ + type: "error", + error: { + code: "invalid_encrypted_content", + message: "The encrypted content could not be verified", + }, + }), + ).toEqual({ + type: "stale_reasoning", + message: "The encrypted content could not be verified", + responseBody: JSON.stringify({ + type: "error", + error: { + code: "invalid_encrypted_content", + message: "The encrypted content could not be verified", + }, + }), + }) + + const error = new APICallError({ + message: + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + url: "https://opencode.ai/zen/v1/responses", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }) + expect(ProviderError.parseAPICallError({ providerID: ProviderV2.ID.make("opencode"), error }).type).toBe( + "stale_reasoning", + ) + }) +}) + +describe("stale reasoning request sanitizer", () => { + test("drops openai replay blobs from reasoning parts", () => { + const part = { + id: "prt_1", + sessionID: "ses_1", + messageID: "msg_1", + type: "reasoning", + text: "thinking", + time: { start: 1 }, + metadata: { + openai: { + itemId: "rs_1", + reasoningEncryptedContent: "blob", + }, + }, + } as SessionV1.ReasoningPart + const next = SessionStaleReasoning.stripPart(part) + expect(next.metadata?.openai).toBeUndefined() + expect(next.text).toBe("thinking") + + const messages = [ + { + role: "assistant" as const, + content: [ + { + type: "reasoning" as const, + text: "thinking", + providerOptions: { openai: { itemId: "rs_1", reasoningEncryptedContent: "blob" } }, + }, + ], + }, + ] + SessionStaleReasoning.stripRequest(messages) + expect(messages[0]?.content[0]).toEqual({ type: "reasoning", text: "thinking" }) + }) }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 734a30e42454..8e045b41fc41 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1488,6 +1488,26 @@ describe("session.message-v2.fromError", () => { expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(true) }) + test("serializes stale encrypted reasoning as a non-retryable APIError", () => { + const error = new APICallError({ + message: + "Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller", + url: "https://opencode.ai/zen/v1/responses", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID }) + expect(SessionV1.APIError.isInstance(result)).toBe(true) + expect(result).toMatchObject({ + name: "APIError", + data: { + isRetryable: false, + }, + }) + }) + test("does not classify 429 no body as context overflow", () => { const result = MessageV2.fromError( new APICallError({