Skip to content
Open
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
7 changes: 6 additions & 1 deletion packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
export { Provider } from "./provider.js"
export { ProviderPackage } from "./provider-package.js"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js"
export {
isContextOverflow,
isContextOverflowFailure,
isStaleReasoning,
isStaleReasoningFailure,
} from "./provider-error.js"
export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
Expand Down
21 changes: 21 additions & 0 deletions packages/ai/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ const patterns = [

const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]

const STALE_REASONING_PATTERNS = [
/reasoning.*encrypted_content.*not issued to this caller/i,
/was not issued to this caller/i,
/invalid_encrypted_content/i,
/encrypted.*content.*could not be decrypted/i,
/referenced reasoning item .* was not found/i,
/referenced reasoning item .* has expired/i,
/reasoning item .* was not found or has expired/i,
/item .* of type ['"]reasoning['"] was provided without its required following item/i,
]

const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]

export const isContextOverflow = (message: string) =>
Expand All @@ -53,11 +64,19 @@ export const isContextOverflow = (message: string) =>

export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))

export const isStaleReasoning = (message: string) => STALE_REASONING_PATTERNS.some((pattern) => pattern.test(message))

export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof AIError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"

export const isStaleReasoningFailure = (failure: unknown) =>
failure instanceof AIError
? (failure.reason._tag === "InvalidRequest" && failure.reason.classification === "stale-reasoning") ||
isStaleReasoning([failure.message, failure.reason.message, failure.reason.body ?? ""].filter(Boolean).join("\n"))
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "stale-reasoning"

const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
Expand Down Expand Up @@ -120,6 +139,8 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (clientScoped && isStaleReasoning(text))
return new InvalidRequestError({ ...details, classification: "stale-reasoning" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
Expand Down
6 changes: 5 additions & 1 deletion packages/ai/src/schema/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"

export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export const ProviderFailureClassification = Schema.Literals([
"context-overflow",
"payload-too-large",
"stale-reasoning",
])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type

export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
Expand Down
15 changes: 15 additions & 0 deletions packages/ai/test/provider-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,19 @@ describe("provider error rawBody classification", () => {
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})

test("classifies stale and invalid encrypted reasoning errors", () => {
const cases = [
"Error from provider (Console): Upstream request failed: [invalid_request_error] reasoning `encrypted_content` was not issued to this caller",
"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.",
]
for (const message of cases) {
const reason = classifyProviderFailure({ message, status: 400 })
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "stale-reasoning" })
}
})
})
40 changes: 39 additions & 1 deletion packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export * as SessionRunnerLLM from "./llm.js"

import { Message } from "@opencode/ai"
import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Cause, Effect, Exit, FiberMap, Layer, Schema } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { InstructionState } from "../instruction-state.js"
Expand Down Expand Up @@ -33,6 +33,30 @@ import { MAX_STEPS_PROMPT } from "./max-steps.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."

const encodeAssistantContent = Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))

const sanitizeReasoning = (messages: readonly SessionMessage.Info[]): SessionMessage.Assistant[] => {
const modified: SessionMessage.Assistant[] = []
for (const message of messages) {
if (message.type !== "assistant") continue
let changed = false
for (const item of message.content) {
if (item.type === "reasoning" && item.state) {
if ("reasoningEncryptedContent" in item.state || "itemId" in item.state) {
const state = { ...item.state } as Record<string, unknown>
delete state.reasoningEncryptedContent
delete state.itemId
;(item as { state?: Record<string, unknown> }).state =
Object.keys(state).length > 0 ? state : undefined
changed = true
}
}
}
if (changed) modified.push(message)
}
return modified
}

const layer = Layer.effect(
Service,
Effect.gen(function* () {
Expand Down Expand Up @@ -203,6 +227,7 @@ const layer = Layer.effect(
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
let recoverStaleReasoning = true
while (true) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
Expand Down Expand Up @@ -255,6 +280,7 @@ const layer = Layer.effect(
retry: proposed,
}),
recoverContinuation,
recoverStaleReasoning,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction
Expand Down Expand Up @@ -287,6 +313,18 @@ const layer = Layer.effect(
RecoverFull: Effect.fnUntraced(function* () {
recoverContinuation = false
}),
RecoverStaleReasoning: Effect.fnUntraced(function* () {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Design sign-off needed: this resumes live publication of session.message.content.updated, which #48043 deliberately retired ("remove message content mutation API"), and which is documented as replay-only (packages/schema/src/session-event.ts:135) and excluded from the public manifest. It is mechanically sound — the projector still folds it, the event never reaches SSE clients (event-feed.ts:49 filters on isOpenCodeEvent), and UsageRecorded sets a precedent for internal durable events the runner publishes live — but it re-opens the content-mutation path that removal closed, minus the guards the old public API enforced (message completed, no unfinished tools, session idle). Before merge, get an explicit ack from the maintainer who owns #48043 that this internal revival is the intended direction; if it lands, the // Replay-only comment should be updated so the file does not misdocument itself. This is not a code defect — it is a merge decision that belongs to the maintainers, flagged so it is made consciously.

recoverStaleReasoning = false
const modified = sanitizeReasoning(loaded.messages)
for (const message of modified) {
const content = encodeAssistantContent(message.content)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
sessionID,
messageID: message.id,
content,
})
}
}),
})
if (completed !== undefined) return completed
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/session/runner/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
LLMClient,
LLMEvent,
isContextOverflowFailure,
isStaleReasoningFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode/ai"
Expand Down Expand Up @@ -37,6 +38,7 @@ export type Outcome = Data.TaggedEnum<{
readonly decision: SessionRunnerRetry.Decision
}
RecoverFull: {}
RecoverStaleReasoning: {}
Compacted: {}
}>
export const Outcome = Data.taggedEnum<Outcome>()
Expand All @@ -53,6 +55,7 @@ interface Input {
retry: boolean,
) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision>
readonly recoverContinuation: boolean
readonly recoverStaleReasoning: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
}
Expand Down Expand Up @@ -169,6 +172,12 @@ export const make = Effect.gen(function* () {
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (
input.recoverStaleReasoning &&
isStaleReasoningFailure(llmFailure) &&
!recorded.outputStarted
)
return Outcome.RecoverStaleReasoning()
const retry =
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
Expand Down
166 changes: 166 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3435,6 +3435,172 @@ describe("SessionRunnerLLM", () => {
])
})

scenario("recovers from stale encrypted reasoning without failing the session", function* (s) {
yield* s.admit("Think first")

yield* s.llm.push(
TestLLM.stop(
LLMEvent.reasoningStart({ id: "reasoning-openai" }),
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-openai",
providerMetadata: {
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
}),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textDelta({ id: "text-1", text: "First answer" }),
LLMEvent.textEnd({ id: "text-1" }),
),
)
yield* s.resume
yield* replaySessionProjection(sessionID)

expect(yield* s.context).toMatchObject([
Expected.user("Think first"),
Expected.assistant({}, [
{
type: "reasoning",
text: "Encrypted thought",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
{
type: "text",
text: "First answer",
},
]),
])

yield* s.admit("Continue")
yield* s.llm.push(
Stream.fail(
new AIError({
reason: new InvalidRequestError({
message: "reasoning `encrypted_content` was not issued to this caller",
classification: "stale-reasoning",
}),
}),
),
TestLLM.text("Recovered answer", "text-recovered"),
)
yield* s.resume

expect(s.requests).toHaveLength(3)
expect(s.requests[1]?.messages[1]?.content).toEqual([
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "text",
text: "First answer",
},
])
expect(s.requests[2]?.messages[1]?.content).toEqual([
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: undefined,
},
{
type: "text",
text: "First answer",
},
])

yield* replaySessionProjection(sessionID)
const context1 = yield* s.context
const firstAssistant1 = requireAssistant(context1)
const reasoning1 = firstAssistant1.content.find((item) => item.type === "reasoning")
expect(reasoning1?.state).toBeUndefined()
expect(context1).toMatchObject([
Expected.user("Think first"),
Expected.assistant({}, [
{
type: "reasoning",
text: "Encrypted thought",
},
{
type: "text",
text: "First answer",
},
]),
Expected.user("Continue"),
Expected.assistant({}, [
{
type: "text",
text: "Recovered answer",
},
]),
])
})

scenario("recovers from stale encrypted reasoning after tool execution without failing", function* (s) {
yield* s.admit("Echo this")

yield* s.llm.push(
TestLLM.toolCalls(
LLMEvent.reasoningStart({ id: "reasoning-tool" }),
LLMEvent.reasoningDelta({ id: "reasoning-tool", text: "Tool thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-tool",
providerMetadata: {
openai: { itemId: "rs_tool", reasoningEncryptedContent: "tool-encrypted-state" },
},
}),
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
),
Stream.fail(
new AIError({
reason: new InvalidRequestError({
message: "Referenced reasoning item 'rs_tool' was not found or has expired",
classification: "stale-reasoning",
}),
}),
),
TestLLM.text("Done after tool", "text-after-tool"),
)

yield* s.resume

// 1st request: user prompt -> returns reasoning + tool call
// 2nd request: continuation with tool result + stale reasoning metadata -> fails with stale-reasoning
// 3rd request: retried continuation with tool result + sanitized reasoning -> succeeds with text
expect(s.requests).toHaveLength(3)
expect(s.executions).toEqual(["hello"])
expect(s.requests[1]?.messages[1]?.content).toContainEqual({
type: "reasoning",
text: "Tool thought",
providerMetadata: { openai: { itemId: "rs_tool", reasoningEncryptedContent: "tool-encrypted-state" } },
})
expect(s.requests[2]?.messages[1]?.content).toContainEqual({
type: "reasoning",
text: "Tool thought",
providerMetadata: undefined,
})

yield* replaySessionProjection(sessionID)
const context2 = yield* s.context
const firstAssistant2 = requireAssistant(context2)
const reasoning2 = firstAssistant2.content.find((item) => item.type === "reasoning")
expect(reasoning2?.state).toBeUndefined()
expect(context2).toMatchObject([
Expected.user("Echo this"),
Expected.assistant({ finish: "tool-calls" }, [
{
type: "reasoning",
text: "Tool thought",
},
Expected.completedTool(
{ id: "call-echo", name: "echo" },
{ input: { text: "hello" }, content: [Expected.text("hello")] },
),
]),
Expected.assistant({ finish: "stop" }, [Expected.text("Done after tool")]),
])
})

scenario("keeps one durable reasoning part when reasoning closes after text", function* (s) {
yield* s.admit("Think and answer")

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/session-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ for (const fixture of [
retry: (_cause, _error, retry) =>
Effect.succeed(retry ? { retry: true, attempt: 2, delay: 0 } : { retry: false }),
recoverContinuation: true,
recoverStaleReasoning: true,
recoverOverflow: Effect.succeed(false),
})
.pipe(Effect.exit)
Expand Down
Loading