From dfd3ed44c5182516d924d5f581b6b924e356adc0 Mon Sep 17 00:00:00 2001 From: nullEFFORT Date: Wed, 10 Jun 2026 08:00:05 -0500 Subject: [PATCH] fix: auto-retry on Claude "Could not process image" 400 during session resume (#3276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The Claude-local adapter resumes prior sessions via `claude --resume ` so work continues across heartbeats. > - When a resumed session contains an image whose content is no longer accessible, Claude returns a 400 "Could not process image" — but the session itself is poisoned and will keep returning the same error on every resume. > - The existing retry path only catches the "unknown session" 400 case; image-processing 400s on resume fall through and the run fails for the user. > - This PR adds an `isClaudeImageProcessingError` detector mirroring `isClaudeUnknownSessionError` and wires it into the same fresh-session retry branch in `execute.ts`. > - The benefit is that a poisoned-image resume self-recovers by retrying once with a fresh session, exactly like the existing unknown-session path. ## Linked Issues or Issue Description Fixes #3275 Refs #3123 ## What Changed - Added `isClaudeImageProcessingError()` in `packages/adapters/claude-local/src/server/parse.ts` that matches `Could not process image` in 400 error messages. - Wired the new detector into the existing session-resume retry branch in `packages/adapters/claude-local/src/server/execute.ts` alongside `isClaudeUnknownSessionError`. - Retry only fires when `sessionId` is present (i.e. we were resuming), so fresh-session runs that hit the same error are not retried (no infinite loop). ## Verification - `pnpm --filter @paperclipai/adapter-claude-local test` covers `parse.ts` patterns and the resume-retry decision branch. - `pnpm --filter @paperclipai/adapter-claude-local typecheck` ## Risks Low. Behavior change is narrowly additive: a previously-fatal 400 on resume now triggers a single fresh-session retry. No effect on fresh-session runs, unknown-session retries, or non-image 400s. ## Model Used Claude (Opus 4.6) — used to mirror the existing unknown-session pattern and verify the guard against infinite loops. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (in progress) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Devin Foley Co-authored-by: Paperclip --- .../claude-local/src/server/execute.ts | 5 +++ .../claude-local/src/server/parse.test.ts | 45 +++++++++++++++++++ .../adapters/claude-local/src/server/parse.ts | 13 +++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 6086cc3e..a2656061 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -55,6 +55,7 @@ import { isClaudeTransientUpstreamError, isClaudeUnknownSessionError, isClaudePoisonedPreviousMessageIdError, + isClaudeImageProcessingError, } from "./parse.js"; import { prepareClaudeConfigSeed, resolveSharedClaudeConfigDir } from "./claude-config.js"; import { resolveClaudeDesiredSkillNames } from "./skills.js"; @@ -983,6 +984,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise { @@ -185,6 +186,50 @@ describe("isClaudeUnknownSessionError", () => { }); }); +describe("isClaudeImageProcessingError", () => { + it("detects the 'Could not process image' 400 error in the result field", () => { + expect( + isClaudeImageProcessingError({ + subtype: "success", + is_error: true, + result: "API Error: 400 Could not process image: image source URL has expired", + }), + ).toBe(true); + }); + + it("detects the error in the errors array", () => { + expect( + isClaudeImageProcessingError({ + is_error: true, + result: "", + errors: [{ message: "400 Could not process image" }], + }), + ).toBe(true); + }); + + it("is case-insensitive", () => { + expect( + isClaudeImageProcessingError({ + is_error: true, + result: "could not process image attached to message", + }), + ).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect( + isClaudeImageProcessingError({ + is_error: true, + result: "No conversation found with session id abc-123", + }), + ).toBe(false); + }); + + it("returns false for empty parsed result", () => { + expect(isClaudeImageProcessingError({})).toBe(false); + }); +}); + describe("extractClaudeRetryNotBefore", () => { it("parses the 'resets 4pm' hint in its explicit timezone", () => { const now = new Date("2026-04-22T15:15:00.000Z"); diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 36d13fd6..fbf66f79 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -209,6 +209,17 @@ export function isClaudePoisonedPreviousMessageIdError(parsed: Record): boolean { + const resultText = asString(parsed.result, "").trim(); + const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)] + .map((msg) => msg.trim()) + .filter(Boolean); + + return allMessages.some((msg) => + /could not process image/i.test(msg), + ); +} + function buildClaudeTransientHaystack(input: { parsed?: Record | null; stdout?: string | null; @@ -388,7 +399,7 @@ export function isClaudeTransientUpstreamError(input: { }): boolean { const parsed = input.parsed ?? null; // Deterministic failures are handled by their own classifiers. - if (parsed && (isClaudeMaxTurnsResult(parsed) || isClaudeUnknownSessionError(parsed) || isClaudePoisonedPreviousMessageIdError(parsed))) { + if (parsed && (isClaudeMaxTurnsResult(parsed) || isClaudeUnknownSessionError(parsed) || isClaudePoisonedPreviousMessageIdError(parsed) || isClaudeImageProcessingError(parsed))) { return false; } const loginMeta = detectClaudeLoginRequired({