From 9b016a4b5d5c81684b14b0fba2f45d85f73d4651 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 17:55:38 +0800 Subject: [PATCH 1/9] fix(session): keep attached PDFs path-backed --- packages/opencode/src/session/prompt.ts | 7 ++++--- packages/opencode/test/session/prompt-effect.test.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c6a09f82f..600a039a9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -251,8 +251,9 @@ export function deriveCommandTitleSeed(parts: ReadonlyArray): st return "Command: /" + name + (args.length > 0 ? " " + args : "") } -function officePathOnly(filepath: string) { - return OFFICE_EXTS.has(pathSuffix(filepath)) +function documentPathOnly(filepath: string) { + const suffix = pathSuffix(filepath) + return OFFICE_EXTS.has(suffix) || suffix === "pdf" } function attachedLocalFileText(filepath: string, filename?: string) { @@ -1537,7 +1538,7 @@ export const layer = Layer.effect( } if (part.mime === "text/plain") { - if (officePathOnly(filepath)) { + if (documentPathOnly(filepath)) { return [ { messageID: info.id, diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index f4042f9c4..07b9b83be 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1530,7 +1530,7 @@ it.live("tells the model when image content cannot be provided to it", () => ), ) -it.live("text file part promotes PDF attachment when model has image input", () => +it.live("text file part keeps PDF path-only when model has image input", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -1551,7 +1551,13 @@ it.live("text file part promotes PDF attachment when model has image input", () ], }) - expect(msg.parts.some((part) => part.type === "file" && part.mime === "application/pdf")).toBe(true) + expect(msg.parts.some((part) => part.type === "file" && part.mime === "application/pdf")).toBe(false) + expect(msg.parts.some((part) => part.type === "file" && part.url === pdfUrl)).toBe(true) + expect( + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text === `Attached local file by path: ${pdf}`, + ), + ).toBe(true) }), { git: true, config: imageCfg }, ), From 0681e7405afb93ccbd9c25ab0d2858f0c0a1ab94 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 18:01:17 +0800 Subject: [PATCH 2/9] fix(session): bound aggregate request media --- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/message-v2.ts | 80 ++++++++++- .../opencode/test/session/message-v2.test.ts | 133 ++++++++++++++++++ 3 files changed, 209 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bd60d7f11..f771d1c25 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -246,7 +246,7 @@ export const layer: Layer.Layer< }) { // Size the retained tail the way it actually re-enters the live prompt: // prompt.ts serializes messages with no options, i.e. full media and - // un-truncated tool output. Only the head is summarized cheaply (stripMedia + // un-truncated tool output. Only the head is summarized cheaply (stripped media // + truncation in processCompaction()); the tail is kept verbatim. Using // those head options here undercounts the tail, so select()/splitTurn() // keep recent turns that overflow the very next live prompt. @@ -493,7 +493,7 @@ export const layer: Layer.Layer< const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, + mediaProjection: "stripped", toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, toolInputMaxChars: TOOL_INPUT_MAX_CHARS, }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c13fa7ae6..ebb73645a 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -870,13 +870,74 @@ function providerMeta(metadata: Record | undefined) { return Object.keys(rest).length > 0 ? rest : undefined } +export type MediaProjection = "normal" | "degraded" | "stripped" + +export interface ModelMessageProjectionOptions { + mediaProjection?: MediaProjection + mediaMaxBytes?: number + mediaMaxCount?: number + toolOutputMaxChars?: number + toolInputMaxChars?: number +} + +const MEDIA_MAX_BYTES = 12 * 1024 * 1024 +const MEDIA_MAX_COUNT = 8 +const MEDIA_DEGRADED_MAX_COUNT = 2 + +function mediaCandidates(input: WithParts[]) { + const candidates: FilePart[] = [] + for (const message of input) { + for (const part of message.parts) { + if (part.type === "file" && isMedia(part.mime)) candidates.push(part) + if (part.type !== "tool" || part.state.status !== "completed" || part.state.time.compacted) continue + for (const attachment of part.state.attachments ?? []) { + if (isMedia(attachment.mime)) candidates.push(attachment) + } + } + } + return candidates +} + +function mediaRequestBytes(part: FilePart) { + if (!part.url.startsWith("data:")) return 0 + const comma = part.url.indexOf(",") + if (comma === -1) return new TextEncoder().encode(part.url).byteLength + return new TextEncoder().encode(part.url.slice(comma + 1)).byteLength +} + +function selectedMedia(input: WithParts[], options?: ModelMessageProjectionOptions) { + const projection = options?.mediaProjection ?? "normal" + const configuredMax = options?.mediaMaxCount ?? MEDIA_MAX_COUNT + const maxCount = + projection === "stripped" + ? 0 + : projection === "degraded" + ? Math.min(configuredMax, MEDIA_DEGRADED_MAX_COUNT) + : configuredMax + const maxBytes = projection === "stripped" ? 0 : (options?.mediaMaxBytes ?? MEDIA_MAX_BYTES) + const selected = new Set() + let usedBytes = 0 + for (const part of mediaCandidates(input).reverse()) { + const bytes = mediaRequestBytes(part) + if (selected.size >= Math.max(0, maxCount) || usedBytes + bytes > Math.max(0, maxBytes)) continue + selected.add(part) + usedBytes += bytes + } + return selected +} + +function omittedMedia(part: Pick) { + return `[Attached ${part.mime}: ${part.filename ?? "file"} omitted to fit the provider request limit]` +} + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number }, + options?: ModelMessageProjectionOptions, ) { const result: UIMessage[] = [] const toolNames = new Set() + const includedMedia = selectedMedia(input, options) // Track media from tool results that need to be injected as user messages // for providers that don't support that media type in tool results. // @@ -949,10 +1010,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { + if (isMedia(part.mime) && !includedMedia.has(part)) { userMessage.parts.push({ type: "text", - text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, + text: omittedMedia(part), }) } else { userMessage.parts.push({ @@ -1018,10 +1079,17 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted + const baseOutputText = part.state.time.compacted ? "[Old tool result content cleared]" : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) + const sourceAttachments = part.state.time.compacted ? [] : (part.state.attachments ?? []) + const omittedAttachments = sourceAttachments.filter( + (attachment) => isMedia(attachment.mime) && !includedMedia.has(attachment), + ) + const outputText = [baseOutputText, ...omittedAttachments.map(omittedMedia)].filter(Boolean).join("\n") + const attachments = sourceAttachments.filter( + (attachment) => !isMedia(attachment.mime) || includedMedia.has(attachment), + ) // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message @@ -1154,7 +1222,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number }, + options?: ModelMessageProjectionOptions, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index f58403d6d..1171fc05e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -551,6 +551,139 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("keeps the most recent media when the aggregate request count is over budget", async () => { + const userID = "m-user-media-budget" + const media = (index: number): MessageV2.FilePart => ({ + ...basePart(userID, `file-${index}`), + type: "file", + mime: "image/png", + filename: `image-${index}.png`, + url: `data:image/png;base64,${Buffer.from(`image-${index}`).toString("base64")}`, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "compare these images", + }, + media(1), + media(2), + media(3), + ] as MessageV2.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, model, { + mediaMaxCount: 2, + }), + ).toStrictEqual([ + { + role: "user", + content: [ + { type: "text", text: "compare these images" }, + { + type: "text", + text: "[Attached image/png: image-1.png omitted to fit the provider request limit]", + }, + { + type: "file", + mediaType: "image/png", + filename: "image-2.png", + data: media(2).url, + }, + { + type: "file", + mediaType: "image/png", + filename: "image-3.png", + data: media(3).url, + }, + ], + }, + ]) + }) + + test("keeps the most recent media when the aggregate request bytes are over budget", async () => { + const userID = "m-user-media-byte-budget" + const media = (index: number): MessageV2.FilePart => ({ + ...basePart(userID, `byte-file-${index}`), + type: "file", + mime: "image/png", + filename: `byte-image-${index}.png`, + url: `data:image/png;base64,${Buffer.from(`data-${index}`).toString("base64")}`, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [media(1), media(2), media(3)] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model, { + mediaMaxBytes: 16, + }) + const serialized = JSON.stringify(messages) + expect(serialized).toContain("byte-image-1.png omitted to fit the provider request limit") + expect(serialized).not.toContain(media(1).url) + expect(serialized).toContain(media(2).url) + expect(serialized).toContain(media(3).url) + }) + + test("applies the aggregate media budget to tool-result attachments", async () => { + const userID = "m-user-tool-media-budget" + const assistantID = "m-assistant-tool-media-budget" + const attachment = (index: number): MessageV2.FilePart => ({ + ...basePart(assistantID, `tool-file-${index}`), + type: "file", + mime: "application/pdf", + filename: `report-${index}.pdf`, + url: `data:application/pdf;base64,${Buffer.from(`pdf-${index}`).toString("base64")}`, + }) + const tool = (index: number): MessageV2.ToolPart => ({ + ...basePart(assistantID, `tool-${index}`), + type: "tool", + callID: `call-${index}`, + tool: "read", + state: { + status: "completed", + input: { filePath: `report-${index}.pdf` }, + output: `read report ${index}`, + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [attachment(index)], + }, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "compare reports", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [tool(1), tool(2)] as MessageV2.Part[], + }, + ] + + const serialized = JSON.stringify( + await MessageV2.toModelMessages(input, model, { + mediaMaxCount: 1, + }), + ) + expect(serialized).toContain("report-1.pdf omitted to fit the provider request limit") + expect(serialized).not.toContain(attachment(1).url.slice(attachment(1).url.indexOf(",") + 1)) + expect(serialized).toContain(attachment(2).url.slice(attachment(2).url.indexOf(",") + 1)) + }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, From 868f4ebc02615844830fc9308db2ac678b030484 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 18:10:30 +0800 Subject: [PATCH 3/9] fix(session): recover oversized provider requests --- packages/opencode/src/provider/error.ts | 49 +++++++- packages/opencode/src/session/message-v2.ts | 25 ++++ packages/opencode/src/session/processor.ts | 102 +++++++++++++++-- packages/opencode/src/session/prompt.ts | 5 + .../opencode/test/session/message-v2.test.ts | 26 ++++- .../test/session/processor-effect.test.ts | 108 ++++++++++++++++++ 6 files changed, 298 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 6bc800ffc..c3df93b3e 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -97,7 +97,6 @@ const OVERFLOW_PATTERNS = [ /context window exceeds limit/i, // MiniMax /exceeded model token limit/i, // Kimi For Coding, Moonshot /context[_ ]length[_ ]exceeded/i, // Generic fallback - /request entity too large/i, // HTTP 413 /context length is only \d+ tokens/i, // vLLM /input length.*exceeds.*context length/i, // vLLM /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error @@ -117,10 +116,13 @@ function isOpenAiErrorRetryable(e: APICallError) { function isOverflow(message: string) { if (OVERFLOW_PATTERNS.some((p) => p.test(message))) return true - // Providers/status patterns handled outside of regex list: - // - Cerebras: often returns "400 (no body)" / "413 (no body)" - // - Mistral: often returns "400 (no body)" / "413 (no body)" - return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) + // Cerebras and Mistral often return "400 (no body)" for context overflow. + // HTTP 413 is deliberately classified as request-body size below. + return /^400\s*(status code)?\s*\(no body\)/i.test(message) +} + +function isRequestTooLarge(message: string) { + return /request entity too large|payload too large|^413\s*(status code)?\s*\(no body\)/i.test(message) } // Billing/quota failures providers report under inconsistent status codes and @@ -312,6 +314,12 @@ export type ParsedStreamError = message: string responseBody: string } + | { + type: "request_too_large" + message: string + responseBody: string + code: "request_too_large" | "payload_too_large" + } | { type: "api_error" message: string @@ -342,6 +350,14 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined const code = typeof error.code === "string" ? error.code : typeof error.type === "string" ? error.type : undefined switch (code) { + case "request_too_large": + case "payload_too_large": + return { + type: "request_too_large", + message: typeof error.message === "string" ? error.message : "Provider request is too large.", + responseBody, + code, + } case "context_length_exceeded": return { type: "context_overflow", @@ -453,6 +469,13 @@ export type ParsedAPICallError = message: string responseBody?: string } + | { + type: "request_too_large" + message: string + statusCode?: number + responseBody?: string + code: string + } | { type: "api_error" message: string @@ -470,7 +493,21 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal const body = json(input.error.responseBody) const code = extractProviderCode(body) const modelUnavailable = isOpenCodeModelUnavailable(input.providerID, body) - if (isOverflow(m) || input.error.statusCode === 413 || code === "context_length_exceeded") { + if ( + input.error.statusCode === 413 || + code === "request_too_large" || + code === "payload_too_large" || + isRequestTooLarge(m) + ) { + return { + type: "request_too_large", + message: m, + statusCode: input.error.statusCode, + responseBody: input.error.responseBody, + code: code ?? "request_too_large", + } + } + if (isOverflow(m) || code === "context_length_exceeded") { return { type: "context_overflow", message: m, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ebb73645a..c1db63fcb 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1489,6 +1489,19 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "request_too_large") { + return new APIError( + { + message: parsed.message, + statusCode: parsed.statusCode, + isRetryable: false, + responseBody: parsed.responseBody, + providerID: ctx.providerID, + providerFailure: { kind: "invalid_request", code: parsed.code }, + }, + { cause: e }, + ).toObject() + } return new APIError( { @@ -1520,6 +1533,18 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "request_too_large") { + return new APIError( + { + message: parsed.message, + isRetryable: false, + responseBody: parsed.responseBody, + providerID: ctx.providerID, + providerFailure: { kind: "invalid_request", code: parsed.code }, + }, + { 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 f9eedc410..2d4d7684d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -49,6 +49,9 @@ export type Result = "compact" | "stop" | "continue" export type Event = LLM.Event type ProcessInput = LLM.StreamInput & { + projectMediaMessages?: ( + projection: Exclude, + ) => Promise toolDrainTimeoutMs?: number } @@ -1738,6 +1741,7 @@ export const layer: Layer.Layer< let processAttemptID: RunObservability.AttemptID | undefined let automaticStreamRetriesUsed = 0 let safeRetryNoticeWritten = false + let mediaProjection: MessageV2.MediaProjection = "normal" const retryStillAllowed = Effect.fn("SessionProcessor.retryStillAllowed")(function* (stage: string) { const lifecycleAction = currentLifecycleCloseAction(ctx.directory) @@ -1771,11 +1775,16 @@ export const layer: Layer.Layer< return { allowed: true as const } }) - const retrySignalFor = (error: unknown) => { + const retrySignalFor = ( + error: unknown, + parsed: NonNullable, + canReduceRequestMedia: boolean, + ) => { const phase = watchdogPhase(error) if (phase) { return { retryable: true, + requestTooLarge: false, message: "Connection timed out" as string | undefined, watchdog: { phase } as { phase: "connect" | "silent_stream" | "unknown" } | undefined, providerFailure: undefined as @@ -1790,7 +1799,6 @@ export const layer: Layer.Layer< // transport disconnect. The provider's own message rides along too so // the halt path renders it without a second parse that could drift // from this classification. - const parsed = parse(error) const apiError = MessageV2.APIError.isInstance(parsed) ? parsed : undefined const providerFailure = apiError?.data.providerFailure ? { @@ -1802,14 +1810,49 @@ export const layer: Layer.Layer< } : undefined const providerMessage = apiError?.data.message + const requestTooLarge = + apiError?.data.statusCode === 413 || + apiError?.data.providerFailure?.code === "request_too_large" || + apiError?.data.providerFailure?.code === "payload_too_large" + if (requestTooLarge) { + return { + retryable: canReduceRequestMedia, + requestTooLarge: true, + message: providerMessage, + watchdog: undefined, + providerFailure, + providerMessage, + } + } const classification = SessionRetry.classifyRetry(parsed) if (!classification) - return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage } + return { + retryable: false, + requestTooLarge: false, + message: undefined, + watchdog: undefined, + providerFailure, + providerMessage, + } if (SessionRetry.retryAction(classification) === "stop") { ctx.terminalClassification = classification - return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage } + return { + retryable: false, + requestTooLarge: false, + message: undefined, + watchdog: undefined, + providerFailure, + providerMessage, + } + } + return { + retryable: true, + requestTooLarge: false, + message: classification.raw, + watchdog: undefined, + providerFailure, + providerMessage, } - return { retryable: true, message: classification.raw, watchdog: undefined, providerFailure, providerMessage } } const removeReasoningForAttempt = Effect.fn("SessionProcessor.removeReasoningForAttempt")(function* ( @@ -1896,10 +1939,20 @@ export const layer: Layer.Layer< }) let stream: Stream.Stream try { + const projectedMedia = mediaProjection === "normal" ? undefined : mediaProjection + const projectMediaMessages = streamInput.projectMediaMessages + const messages = + projectedMedia === undefined + ? streamInput.messages + : projectMediaMessages + ? yield* Effect.promise(() => projectMediaMessages(projectedMedia)) + : streamInput.messages + const { projectMediaMessages: _, toolDrainTimeoutMs: __, ...llmInput } = streamInput stream = llm.stream({ - ...ProviderTransform.streamTimeouts(streamInput.model), - ...streamInput, + ...ProviderTransform.streamTimeouts(llmInput.model), + ...llmInput, ...sessionTimeouts, + messages, tools: activeTools, trace: ctx.trace, toolAbortSignal: toolAbortController.signal, @@ -1943,7 +1996,14 @@ export const layer: Layer.Layer< if (result.ok !== false) break const attemptID = processAttemptID - const retrySignal = retrySignalFor(result.error) + const parsedError = parse(result.error) + const nextMediaProjection = + mediaProjection === "normal" ? "degraded" : mediaProjection === "degraded" ? "stripped" : undefined + const retrySignal = retrySignalFor( + result.error, + parsedError, + nextMediaProjection !== undefined && streamInput.projectMediaMessages !== undefined, + ) yield* finalizeToolLifecycles() const decision = ctx.runTrace.recordAttemptFailureAndDeriveRecovery({ attemptID, @@ -2003,6 +2063,32 @@ export const layer: Layer.Layer< break } + if ( + attemptID && + nextMediaProjection && + retrySignal.requestTooLarge && + retryDecision.canRetry && + retryDecision.recoveryMode === "replay" + ) { + const beforeRetry = yield* retryStillAllowed("before_media_projection_retry") + if (beforeRetry.allowed) { + automaticStreamRetriesUsed += 1 + mediaProjection = nextMediaProjection + yield* removeReasoningForAttempt(attemptID) + ctx.runTrace.recordAutoRetryAttempted({ + attemptID, + at: Date.now(), + monotonicMs: performance.now(), + }) + continue + } + yield* halt(result.error, attemptID, { + recordFailure: false, + interruptionMessage: beforeRetry.interruptionMessage, + }) + break + } + if (attemptID && retryDecision.canRetry && retryDecision.recoveryMode === "replay") { const beforeRetry = yield* retryStillAllowed("before_backoff") if (beforeRetry.allowed) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 600a039a9..943358c24 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2281,6 +2281,11 @@ export const layer = Layer.effect( parentSessionID: session.parentID, system, messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + projectMediaMessages: (projection) => + MessageV2.toModelMessages(msgs, model, { mediaProjection: projection }).then((messages) => [ + ...messages, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), + ]), tools, availableDeferredTools: resolvedTools.availableDeferredTools, model, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1171fc05e..4f724849a 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -551,7 +551,7 @@ describe("session.message-v2.toModelMessage", () => { }) }) - test("keeps the most recent media when the aggregate request count is over budget", async () => { + test("keeps the two most recent media in the degraded request projection", async () => { const userID = "m-user-media-budget" const media = (index: number): MessageV2.FilePart => ({ ...basePart(userID, `file-${index}`), @@ -578,7 +578,7 @@ describe("session.message-v2.toModelMessage", () => { expect( await MessageV2.toModelMessages(input, model, { - mediaMaxCount: 2, + mediaProjection: "degraded", }), ).toStrictEqual([ { @@ -2019,7 +2019,6 @@ describe("session.message-v2.fromError", () => { "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", "Please reduce the length of the messages or completion", "400 status code (no body)", - "413 status code (no body)", ] cases.forEach((message) => { @@ -2036,6 +2035,27 @@ describe("session.message-v2.fromError", () => { }) }) + test("classifies HTTP 413 as request size overflow instead of context overflow", () => { + const error = new APICallError({ + message: "413 status code (no body)", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(result).toMatchObject({ + name: "APIError", + data: { + statusCode: 413, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) + }) + test("detects context overflow from context_length_exceeded code in response body", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d2713e7ae..48e866223 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1517,6 +1517,114 @@ it.live("surfaces a terminal provider API error's real message instead of a conn ), ) +it.live("retries HTTP 413 with degraded then stripped request media projections", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.text("recovered") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media" }], + projectMediaMessages: (projection) => + Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + tools: {}, + }) + + const inputs = yield* llm.inputs + expect(value).toBe("continue") + expect( + inputs.map( + (input) => (input.messages as Array<{ content?: unknown }> | undefined)?.at(-1)?.content, + ), + ).toEqual(["normal media", "degraded media", "stripped media"]) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + +it.live("stops after the stripped request media projection is also rejected with HTTP 413", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.text("must not run") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media" }], + projectMediaMessages: (projection) => + Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + tools: {}, + }) + + expect(value).toBe("stop") + expect(yield* llm.calls).toBe(3) + expect(handle.message.error).toMatchObject({ + name: "APIError", + data: { + statusCode: 413, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests retry recognized structured json errors", () => provideTmpdirServer( ({ dir, llm }) => From bab2845bbea86b8a43e7706209a20a16711a3119 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 18:19:43 +0800 Subject: [PATCH 4/9] fix(session): tighten oversized request recovery --- packages/opencode/src/provider/error.ts | 19 ++--- packages/opencode/src/session/message-v2.ts | 12 +++ packages/opencode/src/session/processor.ts | 44 ++++++----- packages/opencode/src/session/prompt.ts | 17 ++-- .../opencode/test/session/message-v2.test.ts | 77 +++++++++++++++++++ .../test/session/processor-effect.test.ts | 20 ++++- 6 files changed, 148 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index c3df93b3e..7f6379d22 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -493,25 +493,22 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal const body = json(input.error.responseBody) const code = extractProviderCode(body) const modelUnavailable = isOpenCodeModelUnavailable(input.providerID, body) - if ( - input.error.statusCode === 413 || - code === "request_too_large" || - code === "payload_too_large" || - isRequestTooLarge(m) - ) { + const requestTooLarge = + code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(m) + if (code === "context_length_exceeded" || (isOverflow(m) && !requestTooLarge)) { return { - type: "request_too_large", + type: "context_overflow", message: m, - statusCode: input.error.statusCode, responseBody: input.error.responseBody, - code: code ?? "request_too_large", } } - if (isOverflow(m) || code === "context_length_exceeded") { + if (input.error.statusCode === 413 || requestTooLarge) { return { - type: "context_overflow", + type: "request_too_large", message: m, + statusCode: input.error.statusCode, responseBody: input.error.responseBody, + code: code ?? "request_too_large", } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c1db63fcb..e6cf0cf32 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -926,6 +926,18 @@ function selectedMedia(input: WithParts[], options?: ModelMessageProjectionOptio return selected } +export function nextMediaProjection(input: WithParts[], current: MediaProjection) { + const projections: Exclude[] = + current === "normal" ? ["degraded", "stripped"] : current === "degraded" ? ["stripped"] : [] + const currentMedia = selectedMedia(input, { mediaProjection: current }) + for (const projection of projections) { + const nextMedia = selectedMedia(input, { mediaProjection: projection }) + if (currentMedia.size !== nextMedia.size || [...currentMedia].some((part) => !nextMedia.has(part))) { + return projection + } + } +} + function omittedMedia(part: Pick) { return `[Attached ${part.mime}: ${part.filename ?? "file"} omitted to fit the provider request limit]` } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2d4d7684d..19055946e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -49,9 +49,12 @@ export type Result = "compact" | "stop" | "continue" export type Event = LLM.Event type ProcessInput = LLM.StreamInput & { - projectMediaMessages?: ( - projection: Exclude, - ) => Promise + nextMediaMessages?: (current: MessageV2.MediaProjection) => + | { + projection: Exclude + messages: () => Promise + } + | undefined toolDrainTimeoutMs?: number } @@ -454,6 +457,11 @@ export const layer: Layer.Layer< providerID: input.model.providerID, aborted, }) + const isRequestTooLarge = (error: NonNullable) => + MessageV2.APIError.isInstance(error) && + (error.data.statusCode === 413 || + error.data.providerFailure?.code === "request_too_large" || + error.data.providerFailure?.code === "payload_too_large") const releaseToolLifecycleWaiters = Effect.fn("SessionProcessor.releaseToolLifecycleWaiters")(function* ( call: ToolLifecycleRecord, @@ -1742,6 +1750,7 @@ export const layer: Layer.Layer< let automaticStreamRetriesUsed = 0 let safeRetryNoticeWritten = false let mediaProjection: MessageV2.MediaProjection = "normal" + let projectMediaMessages: (() => Promise) | undefined const retryStillAllowed = Effect.fn("SessionProcessor.retryStillAllowed")(function* (stage: string) { const lifecycleAction = currentLifecycleCloseAction(ctx.directory) @@ -1810,10 +1819,7 @@ export const layer: Layer.Layer< } : undefined const providerMessage = apiError?.data.message - const requestTooLarge = - apiError?.data.statusCode === 413 || - apiError?.data.providerFailure?.code === "request_too_large" || - apiError?.data.providerFailure?.code === "payload_too_large" + const requestTooLarge = isRequestTooLarge(parsed) if (requestTooLarge) { return { retryable: canReduceRequestMedia, @@ -1939,15 +1945,9 @@ export const layer: Layer.Layer< }) let stream: Stream.Stream try { - const projectedMedia = mediaProjection === "normal" ? undefined : mediaProjection - const projectMediaMessages = streamInput.projectMediaMessages - const messages = - projectedMedia === undefined - ? streamInput.messages - : projectMediaMessages - ? yield* Effect.promise(() => projectMediaMessages(projectedMedia)) - : streamInput.messages - const { projectMediaMessages: _, toolDrainTimeoutMs: __, ...llmInput } = streamInput + const projector = projectMediaMessages + const messages = projector ? yield* Effect.promise(() => projector()) : streamInput.messages + const { nextMediaMessages: _, toolDrainTimeoutMs: __, ...llmInput } = streamInput stream = llm.stream({ ...ProviderTransform.streamTimeouts(llmInput.model), ...llmInput, @@ -1997,12 +1997,13 @@ export const layer: Layer.Layer< const attemptID = processAttemptID const parsedError = parse(result.error) - const nextMediaProjection = - mediaProjection === "normal" ? "degraded" : mediaProjection === "degraded" ? "stripped" : undefined + const nextMedia = isRequestTooLarge(parsedError) + ? streamInput.nextMediaMessages?.(mediaProjection) + : undefined const retrySignal = retrySignalFor( result.error, parsedError, - nextMediaProjection !== undefined && streamInput.projectMediaMessages !== undefined, + nextMedia !== undefined, ) yield* finalizeToolLifecycles() const decision = ctx.runTrace.recordAttemptFailureAndDeriveRecovery({ @@ -2065,7 +2066,7 @@ export const layer: Layer.Layer< if ( attemptID && - nextMediaProjection && + nextMedia && retrySignal.requestTooLarge && retryDecision.canRetry && retryDecision.recoveryMode === "replay" @@ -2073,7 +2074,8 @@ export const layer: Layer.Layer< const beforeRetry = yield* retryStillAllowed("before_media_projection_retry") if (beforeRetry.allowed) { automaticStreamRetriesUsed += 1 - mediaProjection = nextMediaProjection + mediaProjection = nextMedia.projection + projectMediaMessages = nextMedia.messages yield* removeReasoningForAttempt(attemptID) ctx.runTrace.recordAutoRetryAttempted({ attemptID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 943358c24..627a60153 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2281,11 +2281,18 @@ export const layer = Layer.effect( parentSessionID: session.parentID, system, messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], - projectMediaMessages: (projection) => - MessageV2.toModelMessages(msgs, model, { mediaProjection: projection }).then((messages) => [ - ...messages, - ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), - ]), + nextMediaMessages: (current) => { + const projection = MessageV2.nextMediaProjection(msgs, current) + if (!projection) return + return { + projection, + messages: () => + MessageV2.toModelMessages(msgs, model, { mediaProjection: projection }).then((messages) => [ + ...messages, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), + ]), + } + }, tools, availableDeferredTools: resolvedTools.availableDeferredTools, model, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 4f724849a..a7c936e0b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -606,6 +606,44 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("has no lower request media projection when history contains no media", () => { + const userID = "m-user-no-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "a large text-only request", + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.nextMediaProjection(input, "normal")).toBeUndefined() + }) + + test("skips degraded projection when it would retain the same media", () => { + const userID = "m-user-one-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "file-1"), + type: "file", + mime: "image/png", + filename: "only-image.png", + url: "data:image/png;base64,aW1hZ2U=", + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.nextMediaProjection(input, "normal")).toBe("stripped") + }) + test("keeps the most recent media when the aggregate request bytes are over budget", async () => { const userID = "m-user-media-byte-budget" const media = (index: number): MessageV2.FilePart => ({ @@ -2056,6 +2094,45 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) }) + test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { + const error = new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window", + code: "context_length_exceeded", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + + test("keeps an explicit context-window message precedence over bare HTTP 413", () => { + const error = new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window of this model", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + test("detects context overflow from context_length_exceeded code in response body", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 48e866223..5999d7e87 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1552,8 +1552,14 @@ it.live("retries HTTP 413 with degraded then stripped request media projections" agent: agent(), system: [], messages: [{ role: "user", content: "normal media" }], - projectMediaMessages: (projection) => - Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + nextMediaMessages: (current) => { + const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined + if (!projection) return + return { + projection, + messages: () => Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + } + }, tools: {}, }) @@ -1606,8 +1612,14 @@ it.live("stops after the stripped request media projection is also rejected with agent: agent(), system: [], messages: [{ role: "user", content: "normal media" }], - projectMediaMessages: (projection) => - Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + nextMediaMessages: (current) => { + const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined + if (!projection) return + return { + projection, + messages: () => Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + } + }, tools: {}, }) From 4b17df23927f93b734f959f92c11e4b18f5448c1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 19:31:28 +0800 Subject: [PATCH 5/9] fix(session): honor request recovery invariants --- packages/opencode/src/provider/error.ts | 3 +- packages/opencode/src/session/message-v2.ts | 20 +++--- .../src/session/run-incident/policy.ts | 3 - .../opencode/test/session/message-v2.test.ts | 69 +++++++++++++++++++ .../test/session/processor-effect.test.ts | 9 ++- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 7f6379d22..295833d44 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -495,7 +495,8 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal const modelUnavailable = isOpenCodeModelUnavailable(input.providerID, body) const requestTooLarge = code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(m) - if (code === "context_length_exceeded" || (isOverflow(m) && !requestTooLarge)) { + const contextOverflow = code === "context_length_exceeded" || isOverflow(m) + if (contextOverflow) { return { type: "context_overflow", message: m, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e6cf0cf32..ece015b64 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -884,9 +884,18 @@ const MEDIA_MAX_BYTES = 12 * 1024 * 1024 const MEDIA_MAX_COUNT = 8 const MEDIA_DEGRADED_MAX_COUNT = 2 +function shouldSerializeMessage(message: WithParts) { + if (message.info.role !== "assistant" || !message.info.error) return true + return ( + AbortedError.isInstance(message.info.error) && + message.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) +} + function mediaCandidates(input: WithParts[]) { const candidates: FilePart[] = [] for (const message of input) { + if (!shouldSerializeMessage(message)) continue for (const part of message.parts) { if (part.type === "file" && isMedia(part.mime)) candidates.push(part) if (part.type !== "tool" || part.state.status !== "completed" || part.state.time.compacted) continue @@ -1006,7 +1015,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( } for (const msg of input) { - if (msg.parts.length === 0) continue + if (msg.parts.length === 0 || !shouldSerializeMessage(msg)) continue if (msg.info.role === "user") { const userMessage: UIMessage = { @@ -1057,15 +1066,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string; filename?: string }> = [] - if ( - msg.info.error && - !( - AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { - continue - } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", diff --git a/packages/opencode/src/session/run-incident/policy.ts b/packages/opencode/src/session/run-incident/policy.ts index 2ebd533be..5f87e93c6 100644 --- a/packages/opencode/src/session/run-incident/policy.ts +++ b/packages/opencode/src/session/run-incident/policy.ts @@ -63,7 +63,6 @@ export function recoveryFor(input: { } if ( canAutoRetryBeforeFirstProviderProgress({ - cause: input.cause, facts: input.facts, terminalFacts, retryableProviderFailure, @@ -193,14 +192,12 @@ export function recoveryFor(input: { } function canAutoRetryBeforeFirstProviderProgress(input: { - cause: TerminalCause facts: IncidentFacts terminalFacts: IncidentFacts retryableProviderFailure: boolean }) { if (!input.retryableProviderFailure) return false if (input.facts.user_cancel_seen || input.facts.lifecycle_close_seen) return false - if (!isBeforeFirstProviderProgressCause(input.cause)) return false if (input.terminalFacts.provider_progress_seen) return false if (!attemptHasNoOutputOrToolActivity(input.terminalFacts)) return false return boundaryAllowsBeforeProgressRetry(input.terminalFacts.side_effect_boundary_snapshot) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a7c936e0b..8dacf2a6b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -644,6 +644,56 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.nextMediaProjection(input, "normal")).toBe("stripped") }) + test("uses only serializable messages when selecting request media", async () => { + const userID = "m-user-visible-media" + const assistantID = "m-assistant-filtered-media" + const media = (messageID: string, id: string): MessageV2.FilePart => ({ + ...basePart(messageID, id), + type: "file", + mime: "image/png", + filename: `${id}.png`, + url: `data:image/png;base64,${Buffer.from(id).toString("base64")}`, + }) + const visible = media(userID, "visible") + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [visible] as MessageV2.Part[], + }, + { + info: assistantInfo( + assistantID, + userID, + new MessageV2.APIError({ + message: "fatal provider failure", + isRetryable: false, + }).toObject() as MessageV2.APIError, + ), + parts: [ + { + ...basePart(assistantID, "tool-filtered-media"), + type: "tool", + callID: "call-filtered-media", + tool: "read", + state: { + status: "completed", + input: {}, + output: "filtered", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: Array.from({ length: 8 }, (_, index) => media(assistantID, `filtered-${index}`)), + }, + }, + ] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(JSON.stringify(messages)).toContain(visible.url) + expect(MessageV2.nextMediaProjection(input, "normal")).toBe("stripped") + }) + test("keeps the most recent media when the aggregate request bytes are over budget", async () => { const userID = "m-user-media-byte-budget" const media = (index: number): MessageV2.FilePart => ({ @@ -2133,6 +2183,25 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) }) + test("keeps an explicit context-window message precedence over the HTTP 413 reason phrase", () => { + const error = new APICallError({ + message: "Payload Too Large", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window of this model", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + test("detects context overflow from context_length_exceeded code in response body", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 5999d7e87..8de656128 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1517,7 +1517,7 @@ it.live("surfaces a terminal provider API error's real message instead of a conn ), ) -it.live("retries HTTP 413 with degraded then stripped request media projections", () => +it.live("retries HTTP 413 media projections with an unclassified local tool boundary", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { @@ -1560,7 +1560,12 @@ it.live("retries HTTP 413 with degraded then stripped request media projections" messages: () => Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), } }, - tools: {}, + tools: { + edit: tool({ + description: "ordinary local tool with incomplete effect classification", + inputSchema: z.object({}), + }), + }, }) const inputs = yield* llm.inputs From 40874d195f8e4c1735bee3e409a8fbb8de81ade9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 20:10:41 +0800 Subject: [PATCH 6/9] fix(session): close oversized retry gaps --- packages/opencode/src/provider/error.ts | 29 ++++++++++- packages/opencode/src/session/message-v2.ts | 26 +++++++--- packages/opencode/src/session/processor.ts | 25 ++++++---- packages/opencode/src/session/prompt.ts | 17 ++----- .../opencode/test/session/message-v2.test.ts | 50 +++++++++++++++++-- .../test/session/processor-effect.test.ts | 8 +-- 6 files changed, 116 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 295833d44..b357830c8 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -329,9 +329,34 @@ export type ParsedStreamError = code?: string } +function parseBareStreamMessage(input: unknown): ParsedStreamError | undefined { + const message = + typeof input === "string" + ? input + : isRecord(input) && typeof input.message === "string" + ? input.message + : undefined + if (!message) return + if (isOverflow(message)) { + return { + type: "context_overflow", + message, + responseBody: message, + } + } + if (isRequestTooLarge(message)) { + return { + type: "request_too_large", + message, + responseBody: message, + code: "request_too_large", + } + } +} + export function parseStreamError(input: unknown): ParsedStreamError | undefined { const raw = json(input) - if (!isRecord(raw)) return + if (!isRecord(raw)) return parseBareStreamMessage(input) const inner = typeof raw.message === "string" ? json(raw.message) : undefined const cause = isRecord(raw.cause) ? raw.cause : undefined @@ -342,7 +367,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined const responseBody = JSON.stringify(body) const error = body.type === "error" && isRecord(body.error) ? body.error : isBareProviderError(body) ? body : undefined - if (!error) return + if (!error) return parseBareStreamMessage(input) // Read code from the resolved error only (never dig into an untyped body — // that is the over-match guard `if (!error) return` above protects). Fall back diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ece015b64..fee8c7c99 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -935,15 +935,29 @@ function selectedMedia(input: WithParts[], options?: ModelMessageProjectionOptio return selected } -export function nextMediaProjection(input: WithParts[], current: MediaProjection) { +function mediaProjectionsAfter(current: MediaProjection) { const projections: Exclude[] = current === "normal" ? ["degraded", "stripped"] : current === "degraded" ? ["stripped"] : [] - const currentMedia = selectedMedia(input, { mediaProjection: current }) + return projections +} + +function modelMessagesBytes(messages: ModelMessage[]) { + return new TextEncoder().encode(JSON.stringify(messages)).byteLength +} + +export async function nextMediaMessages( + input: WithParts[], + model: Provider.Model, + current: MediaProjection, + currentMessages: ModelMessage[], + suffix: ModelMessage[] = [], +) { + const currentBytes = modelMessagesBytes(currentMessages) + const projections = mediaProjectionsAfter(current) for (const projection of projections) { - const nextMedia = selectedMedia(input, { mediaProjection: projection }) - if (currentMedia.size !== nextMedia.size || [...currentMedia].some((part) => !nextMedia.has(part))) { - return projection - } + const messages = [...(await toModelMessages(input, model, { mediaProjection: projection })), ...suffix] + if (modelMessagesBytes(messages) >= currentBytes) continue + return { projection, messages } } } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 19055946e..8703a1429 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -49,12 +49,16 @@ export type Result = "compact" | "stop" | "continue" export type Event = LLM.Event type ProcessInput = LLM.StreamInput & { - nextMediaMessages?: (current: MessageV2.MediaProjection) => + nextMediaMessages?: ( + current: MessageV2.MediaProjection, + currentMessages: LLM.StreamInput["messages"], + ) => Promise< | { projection: Exclude - messages: () => Promise + messages: LLM.StreamInput["messages"] } | undefined + > toolDrainTimeoutMs?: number } @@ -1750,7 +1754,8 @@ export const layer: Layer.Layer< let automaticStreamRetriesUsed = 0 let safeRetryNoticeWritten = false let mediaProjection: MessageV2.MediaProjection = "normal" - let projectMediaMessages: (() => Promise) | undefined + let projectedMediaMessages: LLM.StreamInput["messages"] | undefined + let currentMessages = streamInput.messages const retryStillAllowed = Effect.fn("SessionProcessor.retryStillAllowed")(function* (stage: string) { const lifecycleAction = currentLifecycleCloseAction(ctx.directory) @@ -1945,8 +1950,8 @@ export const layer: Layer.Layer< }) let stream: Stream.Stream try { - const projector = projectMediaMessages - const messages = projector ? yield* Effect.promise(() => projector()) : streamInput.messages + const messages = projectedMediaMessages ?? streamInput.messages + currentMessages = messages const { nextMediaMessages: _, toolDrainTimeoutMs: __, ...llmInput } = streamInput stream = llm.stream({ ...ProviderTransform.streamTimeouts(llmInput.model), @@ -1997,9 +2002,11 @@ export const layer: Layer.Layer< const attemptID = processAttemptID const parsedError = parse(result.error) - const nextMedia = isRequestTooLarge(parsedError) - ? streamInput.nextMediaMessages?.(mediaProjection) - : undefined + const nextMedia = yield* Effect.promise(() => + isRequestTooLarge(parsedError) && streamInput.nextMediaMessages + ? streamInput.nextMediaMessages(mediaProjection, currentMessages) + : Promise.resolve(undefined), + ) const retrySignal = retrySignalFor( result.error, parsedError, @@ -2075,7 +2082,7 @@ export const layer: Layer.Layer< if (beforeRetry.allowed) { automaticStreamRetriesUsed += 1 mediaProjection = nextMedia.projection - projectMediaMessages = nextMedia.messages + projectedMediaMessages = nextMedia.messages yield* removeReasoningForAttempt(attemptID) ctx.runTrace.recordAutoRetryAttempted({ attemptID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 627a60153..bc0d285d1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2273,6 +2273,7 @@ export const layer = Layer.effect( ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + const messageSuffix = isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [] const result = yield* handle.process({ user: lastUser, agent, @@ -2280,19 +2281,9 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], - nextMediaMessages: (current) => { - const projection = MessageV2.nextMediaProjection(msgs, current) - if (!projection) return - return { - projection, - messages: () => - MessageV2.toModelMessages(msgs, model, { mediaProjection: projection }).then((messages) => [ - ...messages, - ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), - ]), - } - }, + messages: [...modelMsgs, ...messageSuffix], + nextMediaMessages: (current, currentMessages) => + MessageV2.nextMediaMessages(msgs, model, current, currentMessages, messageSuffix), tools, availableDeferredTools: resolvedTools.availableDeferredTools, model, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 8dacf2a6b..702ae5881 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -606,7 +606,7 @@ describe("session.message-v2.toModelMessage", () => { ]) }) - test("has no lower request media projection when history contains no media", () => { + test("has no lower request media projection when history contains no media", async () => { const userID = "m-user-no-media" const input: MessageV2.WithParts[] = [ { @@ -621,10 +621,11 @@ describe("session.message-v2.toModelMessage", () => { }, ] - expect(MessageV2.nextMediaProjection(input, "normal")).toBeUndefined() + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toBeUndefined() }) - test("skips degraded projection when it would retain the same media", () => { + test("skips degraded projection when it would retain the same media", async () => { const userID = "m-user-one-media" const input: MessageV2.WithParts[] = [ { @@ -641,7 +642,29 @@ describe("session.message-v2.toModelMessage", () => { }, ] - expect(MessageV2.nextMediaProjection(input, "normal")).toBe("stripped") + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toMatchObject({ + projection: "stripped", + }) + }) + + test("has no lower request projection when omission markers would not shrink the payload", async () => { + const userID = "m-user-tiny-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: Array.from({ length: 3 }, (_, index) => ({ + ...basePart(userID, `tiny-${index}`), + type: "file", + mime: "image/png", + filename: "x", + url: "data:image/png;base64,", + })) as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toBeUndefined() }) test("uses only serializable messages when selecting request media", async () => { @@ -691,7 +714,9 @@ describe("session.message-v2.toModelMessage", () => { const messages = await MessageV2.toModelMessages(input, model) expect(JSON.stringify(messages)).toContain(visible.url) - expect(MessageV2.nextMediaProjection(input, "normal")).toBe("stripped") + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toMatchObject({ + projection: "stripped", + }) }) test("keeps the most recent media when the aggregate request bytes are over budget", async () => { @@ -2144,6 +2169,21 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) }) + test("classifies bare request-size errors without structured provider metadata", () => { + const cases = ["413 status code (no body)", "Payload Too Large", "Request Entity Too Large"] + + for (const message of cases) { + expect(MessageV2.fromError(new Error(message), { providerID })).toMatchObject({ + name: "APIError", + data: { + message, + isRetryable: false, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + } + }) + test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 8de656128..903c5ecec 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1552,12 +1552,12 @@ it.live("retries HTTP 413 media projections with an unclassified local tool boun agent: agent(), system: [], messages: [{ role: "user", content: "normal media" }], - nextMediaMessages: (current) => { + nextMediaMessages: async (current) => { const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined if (!projection) return return { projection, - messages: () => Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + messages: [{ role: "user" as const, content: `${projection} media` }], } }, tools: { @@ -1617,12 +1617,12 @@ it.live("stops after the stripped request media projection is also rejected with agent: agent(), system: [], messages: [{ role: "user", content: "normal media" }], - nextMediaMessages: (current) => { + nextMediaMessages: async (current) => { const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined if (!projection) return return { projection, - messages: () => Promise.resolve([{ role: "user" as const, content: `${projection} media` }]), + messages: [{ role: "user" as const, content: `${projection} media` }], } }, tools: {}, From 1beabca630246401c465ae88e19f4abf62178486 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 20:21:31 +0800 Subject: [PATCH 7/9] fix(session): normalize typed stream size errors --- packages/opencode/src/provider/error.ts | 16 +++++ .../opencode/test/session/message-v2.test.ts | 30 +++++++++ .../test/session/processor-effect.test.ts | 66 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index b357830c8..ef3e6a632 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -451,6 +451,22 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined } const providerMessage = typeof error.message === "string" ? error.message : undefined + if (providerMessage && isOverflow(providerMessage)) { + return { + type: "context_overflow", + message: providerMessage, + responseBody, + } + } + if (providerMessage && isRequestTooLarge(providerMessage)) { + return { + type: "request_too_large", + message: providerMessage, + responseBody, + code: "request_too_large", + } + } + const text = `${providerMessage ?? ""}\n${responseBody}` // No statusCode on the stream path, so only the unconditional strong billing // patterns can match here (weak patterns are status-gated). diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 702ae5881..b66e3ce93 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -2184,6 +2184,36 @@ describe("session.message-v2.fromError", () => { } }) + test("classifies message-only typed stream request-size errors", () => { + const cases = ["413 status code (no body)", "Payload Too Large", "Request Entity Too Large"] + + for (const message of cases) { + const payload = JSON.stringify({ type: "error", error: { message } }) + expect(MessageV2.fromError(new Error(payload), { providerID })).toMatchObject({ + name: "APIError", + data: { + message, + isRetryable: false, + responseBody: payload, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + } + }) + + test("keeps explicit context-window evidence ahead of request-size text in typed stream errors", () => { + const payload = JSON.stringify({ + type: "error", + error: { + message: "Input exceeds the context window of this model. Payload Too Large.", + }, + }) + + expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(new Error(payload), { providerID }))).toBe( + true, + ) + }) + test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 903c5ecec..4b96a4168 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1581,6 +1581,72 @@ it.live("retries HTTP 413 media projections with an unclassified local tool boun ), ) +it.live("retries a message-only typed stream 413 with a smaller media projection", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [{ type: "error", error: { message: "Payload Too Large" } }], + }), + ) + yield* llm.text("recovered") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + const originalMessages = [{ role: "user" as const, content: "normal media payload" }] + const projectedMessages = [{ role: "user" as const, content: "stripped" }] + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: originalMessages, + nextMediaMessages: async (current) => + current === "normal" + ? { + projection: "stripped", + messages: projectedMessages, + } + : undefined, + tools: {}, + }) + + const inputs = yield* llm.inputs + const inputMessages = inputs.map((input) => input.messages) + const bytes = (messages: unknown) => new TextEncoder().encode(JSON.stringify(messages)).byteLength + expect(value).toBe("continue") + expect( + inputMessages.map( + (messages) => (messages as Array<{ content?: unknown }> | undefined)?.at(-1)?.content, + ), + ).toEqual(["normal media payload", "stripped"]) + expect(bytes(inputMessages[1])).toBeLessThan(bytes(inputMessages[0])) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("stops after the stripped request media projection is also rejected with HTTP 413", () => provideTmpdirServer( ({ dir, llm }) => From 481f668ce60f04fa7a6286f3a9d20c06ae71ff9a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 20:29:46 +0800 Subject: [PATCH 8/9] fix(session): preserve context overflow precedence --- packages/opencode/src/provider/error.ts | 17 ++--- .../opencode/test/session/message-v2.test.ts | 21 +++--- .../test/session/processor-effect.test.ts | 65 +++++++++++++++++++ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index ef3e6a632..aa2022496 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -374,6 +374,15 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined // to error.type so providers that put the code under `type` still classify. const code = typeof error.code === "string" ? error.code : typeof error.type === "string" ? error.type : undefined + const providerMessage = typeof error.message === "string" ? error.message : undefined + if (providerMessage && isOverflow(providerMessage)) { + return { + type: "context_overflow", + message: providerMessage, + responseBody, + } + } + switch (code) { case "request_too_large": case "payload_too_large": @@ -450,14 +459,6 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined } } - const providerMessage = typeof error.message === "string" ? error.message : undefined - if (providerMessage && isOverflow(providerMessage)) { - return { - type: "context_overflow", - message: providerMessage, - responseBody, - } - } if (providerMessage && isRequestTooLarge(providerMessage)) { return { type: "request_too_large", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index b66e3ce93..8a091aad8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -2202,16 +2202,19 @@ describe("session.message-v2.fromError", () => { }) test("keeps explicit context-window evidence ahead of request-size text in typed stream errors", () => { - const payload = JSON.stringify({ - type: "error", - error: { - message: "Input exceeds the context window of this model. Payload Too Large.", - }, - }) + for (const code of [undefined, "request_too_large", "payload_too_large"]) { + const payload = JSON.stringify({ + type: "error", + error: { + code, + message: "Input exceeds the context window of this model. Payload Too Large.", + }, + }) - expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(new Error(payload), { providerID }))).toBe( - true, - ) + expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(new Error(payload), { providerID }))).toBe( + true, + ) + } }) test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 4b96a4168..142a4b787 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1647,6 +1647,71 @@ it.live("retries a message-only typed stream 413 with a smaller media projection ), ) +it.live("compacts typed stream context overflow before considering request-size projection", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [ + { + type: "error", + error: { + code: "request_too_large", + message: "Input exceeds the context window of this model. Payload Too Large.", + }, + }, + ], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + let projectionCalls = 0 + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media payload" }], + nextMediaMessages: async () => { + projectionCalls += 1 + return { + projection: "stripped", + messages: [{ role: "user", content: "stripped" }], + } + }, + tools: {}, + }) + + expect(value).toBe("compact") + expect(yield* llm.calls).toBe(1) + expect(projectionCalls).toBe(0) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("stops after the stripped request media projection is also rejected with HTTP 413", () => provideTmpdirServer( ({ dir, llm }) => From dbf7ee391df6b9080f2b1f3709759374ac4e8dbd Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 23 Jul 2026 20:41:55 +0800 Subject: [PATCH 9/9] fix(session): unify request media and error evidence --- packages/opencode/src/provider/error.ts | 37 ++----- packages/opencode/src/util/media.ts | 7 +- .../opencode/test/session/message-v2.test.ts | 104 ++++++++++++++++++ 3 files changed, 122 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index aa2022496..1b1d7fcbe 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -375,29 +375,25 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined const code = typeof error.code === "string" ? error.code : typeof error.type === "string" ? error.type : undefined const providerMessage = typeof error.message === "string" ? error.message : undefined - if (providerMessage && isOverflow(providerMessage)) { + const wrapperMessage = typeof raw.message === "string" && !isStreamErrorBody(inner) ? raw.message : undefined + const evidence = `${providerMessage ?? ""}\n${typeof raw.message === "string" ? raw.message : ""}` + if (code === "context_length_exceeded" || isOverflow(evidence)) { return { type: "context_overflow", - message: providerMessage, + message: providerMessage ?? wrapperMessage ?? "Input exceeds context window of this model", responseBody, } } + if (code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(evidence)) { + return { + type: "request_too_large", + message: providerMessage ?? wrapperMessage ?? "Provider request is too large.", + responseBody, + code: code === "payload_too_large" ? code : "request_too_large", + } + } switch (code) { - case "request_too_large": - case "payload_too_large": - return { - type: "request_too_large", - message: typeof error.message === "string" ? error.message : "Provider request is too large.", - responseBody, - code, - } - case "context_length_exceeded": - return { - type: "context_overflow", - message: "Input exceeds context window of this model", - responseBody, - } case "insufficient_quota": return { type: "api_error", @@ -459,15 +455,6 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined } } - if (providerMessage && isRequestTooLarge(providerMessage)) { - return { - type: "request_too_large", - message: providerMessage, - responseBody, - code: "request_too_large", - } - } - const text = `${providerMessage ?? ""}\n${responseBody}` // No statusCode on the stream path, so only the unconditional strong billing // patterns can match here (weak patterns are status-gated). diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts index 054e9c527..63e7b40c1 100644 --- a/packages/opencode/src/util/media.ts +++ b/packages/opencode/src/util/media.ts @@ -15,7 +15,12 @@ export function isPdfAttachment(mime: string) { } export function isMedia(mime: string) { - return mime.startsWith("image/") || isPdfAttachment(mime) + return ( + mime.startsWith("image/") || + mime.startsWith("audio/") || + mime.startsWith("video/") || + isPdfAttachment(mime) + ) } export function isImageAttachment(mime: string) { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 8a091aad8..db9d79f75 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -797,6 +797,70 @@ describe("session.message-v2.toModelMessage", () => { expect(serialized).toContain(attachment(2).url.slice(attachment(2).url.indexOf(",") + 1)) }) + test("strips audio and video tool-result attachments from provider messages", async () => { + const userID = "m-user-tool-av-budget" + const assistantID = "m-assistant-tool-av-budget" + const audioData = "audio-payload".repeat(100) + const videoData = "video-payload".repeat(100) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "inspect media", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "tool-av"), + type: "tool", + callID: "call-av", + tool: "mcp_resource", + state: { + status: "completed", + input: {}, + output: "media resources", + title: "MCP resource", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "audio-file"), + type: "file", + mime: "audio/mpeg", + filename: "recording.mp3", + url: `data:audio/mpeg;base64,${audioData}`, + }, + { + ...basePart(assistantID, "video-file"), + type: "file", + mime: "video/mp4", + filename: "recording.mp4", + url: `data:video/mp4;base64,${videoData}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + const serialized = JSON.stringify( + await MessageV2.toModelMessages(input, model, { + mediaProjection: "stripped", + }), + ) + expect(serialized).toContain("audio/mpeg: recording.mp3 omitted to fit the provider request limit") + expect(serialized).toContain("video/mp4: recording.mp4 omitted to fit the provider request limit") + expect(serialized).not.toContain(audioData) + expect(serialized).not.toContain(videoData) + }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, @@ -2217,6 +2281,46 @@ describe("session.message-v2.fromError", () => { } }) + test("uses typed stream top-level and wrapper messages as classification evidence", () => { + const contextMessage = "Input exceeds the context window of this model" + const requestBody = { + type: "error", + error: { + code: "request_too_large", + }, + } + const topLevel = { + ...requestBody, + message: contextMessage, + } + const wrapped = new Error(contextMessage, { + cause: { + body: requestBody, + }, + }) + + for (const error of [topLevel, wrapped]) { + expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(error, { providerID }))).toBe(true) + } + + expect( + MessageV2.fromError( + { + type: "error", + message: "Payload Too Large", + error: {}, + }, + { providerID }, + ), + ).toMatchObject({ + name: "APIError", + data: { + message: "Payload Too Large", + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + }) + test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { const error = new APICallError({ message: "Request failed",