diff --git a/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts b/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts new file mode 100644 index 00000000000..2fb496918dd --- /dev/null +++ b/packages/adapters/claude-local/src/server/execute.max-turns-classification.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { runAdapterExecutionTargetProcess } = vi.hoisted(() => ({ + runAdapterExecutionTargetProcess: vi.fn(), +})); + +vi.mock("./acp.js", () => ({ + createClaudeAcpExecutor: () => vi.fn(), + resolveClaudeExecutionEngineForRun: async () => ({ engine: "cli", explicit: true }), +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "claude"), + runAdapterExecutionTargetProcess, + }; +}); + +import { execute } from "./execute.js"; + +// Tool output the agent itself printed. It carries the literal phrase the +// model-not-found detector scans for, but the provider never returned it. +const POISONED_TOOL_OUTPUT = + 'execute.ts:1199: : failed && isClaudeModelNotFoundError({ ... ? "model_not_found"'; + +function buildProcResult(resultEvent: Record, extraStdout = "") { + return { + exitCode: 1, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }), + ...(extraStdout + ? [ + JSON.stringify({ + type: "assistant", + session_id: "claude-session-1", + message: { content: [{ type: "text", text: extraStdout }] }, + }), + ] + : []), + JSON.stringify({ type: "result", session_id: "claude-session-1", ...resultEvent }), + ].join("\n"), + stderr: "", + pid: 321, + startedAt: new Date().toISOString(), + }; +} + +function buildContext() { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { engine: "cli" }, + context: {}, + onLog: vi.fn(async () => {}), + }; +} + +describe("claude_local failure classification precedence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps max_turns_exhausted when stdout mentions model_not_found", async () => { + runAdapterExecutionTargetProcess.mockResolvedValue( + buildProcResult({ is_error: true, subtype: "error_max_turns" }, POISONED_TOOL_OUTPUT), + ); + + const result = await execute(buildContext() as never); + + expect(result.errorCode).toBe("max_turns_exhausted"); + }); + + it("still reports model_not_found when the provider returns it", async () => { + runAdapterExecutionTargetProcess.mockResolvedValue( + buildProcResult({ is_error: true, subtype: "error", result: "API Error: 404 model not found" }), + ); + + const result = await execute(buildContext() as never); + + expect(result.errorCode).toBe("model_not_found"); + }); +}); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index de03c588c03..ac7198ebe6d 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1224,7 +1224,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise