fix: auto-retry on Claude "Could not process image" 400 during session resume (#3276)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The Claude-local adapter resumes prior sessions via `claude --resume <session-id>` 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 <devin@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<AdapterExec
|
||||
? "unknown"
|
||||
: isClaudePoisonedPreviousMessageIdError(initial.parsed)
|
||||
? "poisoned"
|
||||
: isClaudeImageProcessingError(initial.parsed)
|
||||
? "image"
|
||||
: null
|
||||
: null;
|
||||
|
||||
@@ -990,6 +993,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const reason =
|
||||
sessionErrorKind === "poisoned"
|
||||
? "returned a poisoned message-id"
|
||||
: sessionErrorKind === "image"
|
||||
? "contains an unprocessable image"
|
||||
: "is unavailable";
|
||||
await onLog(
|
||||
"stdout",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isClaudeTransientUpstreamError,
|
||||
isClaudePoisonedPreviousMessageIdError,
|
||||
isClaudeUnknownSessionError,
|
||||
isClaudeImageProcessingError,
|
||||
} from "./parse.js";
|
||||
|
||||
describe("isClaudeTransientUpstreamError", () => {
|
||||
@@ -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");
|
||||
|
||||
@@ -209,6 +209,17 @@ export function isClaudePoisonedPreviousMessageIdError(parsed: Record<string, un
|
||||
);
|
||||
}
|
||||
|
||||
export function isClaudeImageProcessingError(parsed: Record<string, unknown>): 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<string, unknown> | 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({
|
||||
|
||||
Reference in New Issue
Block a user