From 5128b4f32307fa8a2e5c5c701bd76574a3ad60f5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:13:27 -0500 Subject: [PATCH] fix(claude): default unset models to Opus 5 (#13055) Resolve unset Claude models to Opus 5 across CLI and ACP execution, preserve explicit and provider-specific overrides, and show the default in agent configuration. Verified 212 focused tests after merging master, UI typecheck and token gates, and all CI checks. Greptile reviewed the final head at 5/5. Co-Authored-By: Paperclip --- docs/adapters/claude-local.md | 19 ++++++++++++- .../adapters/claude-local/src/index.test.ts | 25 +++++++++++++++++ packages/adapters/claude-local/src/index.ts | 25 ++++++++++++++++- .../claude-local/src/server/acp.test.ts | 22 +++++++++++++++ .../adapters/claude-local/src/server/acp.ts | 18 ++++++++++-- .../src/server/execute.remote.test.ts | 4 +++ .../claude-local/src/server/execute.ts | 7 +++-- .../adapters/claude-local/src/server/test.ts | 4 +-- .../__tests__/claude-local-execute.test.ts | 28 +++++++++++++++++++ .../AgentConfigForm.render.test.tsx | 11 ++++++++ ui/src/components/AgentConfigForm.tsx | 2 ++ 11 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 packages/adapters/claude-local/src/index.test.ts diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index e158877a66..d18ff1c7a9 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -17,7 +17,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | Field | Type | Required | Description | |-------|------|----------|-------------| | `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | -| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) | +| `model` | string | No | Claude model to use (default: `claude-opus-5`) | | `promptTemplate` | string | No | Prompt used for all runs | | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | @@ -25,6 +25,23 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | | `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | +## Default model + +An omitted, empty, or whitespace-only `model` uses Claude Opus 5 +(`claude-opus-5`) on both the CLI and ACP engines. This also applies to existing +agents with an unset model, including agents created through the API and agents +running in sandboxes. No database migration is needed. The editor shows the +Paperclip default and leaves the setting unset until you select a model. + +An explicit `model` takes precedence over `ANTHROPIC_MODEL`. When only +`ANTHROPIC_MODEL` is configured, the adapter keeps that override. Bedrock and +Vertex configurations without an explicit model keep their provider-specific +default because those providers use different model IDs. Host environment +settings apply only to local targets when resolving the model. + +The default does not change explicitly configured agent models or the separate +Paperclip Runner's qualified provider profiles. + ## Prompt Templates Templates support `{{variable}}` substitution: diff --git a/packages/adapters/claude-local/src/index.test.ts b/packages/adapters/claude-local/src/index.test.ts new file mode 100644 index 0000000000..8add6054b5 --- /dev/null +++ b/packages/adapters/claude-local/src/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js"; + +describe("Claude model defaults", () => { + it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => { + expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5"); + expect(resolveClaudeModel(model)).toBe("claude-opus-5"); + }); + + it("keeps explicit model IDs ahead of environment overrides", () => { + expect(resolveClaudeModel(" claude-sonnet-4-5 ", { ANTHROPIC_MODEL: "opus" })) + .toBe("claude-sonnet-4-5"); + expect(resolveClaudeModel("", { ANTHROPIC_MODEL: " custom-model " })).toBe("custom-model"); + }); + + it.each([ + { CLAUDE_CODE_USE_BEDROCK: "1" }, + { CLAUDE_CODE_USE_BEDROCK: "true" }, + { ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example" }, + { CLAUDE_CODE_USE_VERTEX: "1" }, + ])("keeps provider-specific defaults for %j", (env) => { + expect(resolveClaudeModel(undefined, env)).toBe(""); + expect(resolveClaudeModel("provider-model", env)).toBe("provider-model"); + }); +}); diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 703a69eef1..1af3540bde 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -1,3 +1,26 @@ +export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5"; + +/** Resolve Paperclip's default without replacing an explicit provider model. */ +export function resolveClaudeModel( + model: unknown, + env: Record = {}, +): string { + const configured = typeof model === "string" ? model.trim() : ""; + if (configured) return configured; + const environmentModel = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL.trim() : ""; + if (environmentModel) return environmentModel; + // These providers use their own model IDs and region-specific defaults. + const providerFlag = (value: unknown) => value === "1" || value === "true"; + if ( + providerFlag(env.CLAUDE_CODE_USE_BEDROCK) + || providerFlag(env.CLAUDE_CODE_USE_VERTEX) + || (typeof env.ANTHROPIC_BEDROCK_BASE_URL === "string" && env.ANTHROPIC_BEDROCK_BASE_URL.trim()) + ) { + return ""; + } + return DEFAULT_CLAUDE_LOCAL_MODEL; +} + export const type = "claude_local"; export const label = "Claude Code"; @@ -25,7 +48,7 @@ Core fields: - engine (string, optional): execution engine. Leave unset/auto to use ACP when prerequisites pass and fall back to the Claude Code CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime -- model (string, optional): Claude model id +- model (string, optional): Claude model id. Missing or blank defaults to ${DEFAULT_CLAUDE_LOCAL_MODEL} in both CLI and ACP, including existing agents. Explicit model IDs and ANTHROPIC_MODEL overrides are preserved. Bedrock/Vertex without an explicit model retain their provider default. - effort (string, optional): reasoning effort passed via --effort (low|medium|high) - chrome (boolean, optional): pass --chrome when running Claude - promptTemplate (string, optional): run prompt template diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 3ec97c6813..d5c4aacb09 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -237,6 +237,28 @@ function buildContext(root: string, overrides: Partial } describe("claude_local ACP lane", () => { + it("uses the same default model in ACP startup and session identity", async () => { + const root = await makeTempRoot("paperclip-claude-acp-default-"); + const meta: AdapterInvocationMeta[] = []; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never, + }); + const result = await execute(buildContext(root, { + onMeta: async (payload) => { meta.push(payload); }, + })); + expect(result.exitCode).toBe(0); + expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5"); + }); + + it("keeps ACP model precedence consistent with CLI and provider overrides", () => { + expect(buildClaudeAcpConfig({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "opus" } })) + .toMatchObject({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "claude-sonnet-4-5" } }); + expect(buildClaudeAcpConfig({}, { ANTHROPIC_MODEL: "custom-model" })) + .toMatchObject({ model: "custom-model", env: { ANTHROPIC_MODEL: "custom-model" } }); + expect(buildClaudeAcpConfig({}, { CLAUDE_CODE_USE_BEDROCK: "1" }).model).toBe(""); + expect(buildClaudeAcpConfig({ env: { CLAUDE_CODE_USE_VERTEX: "1" } }).model).toBe(""); + }); + it("maps Claude config to the ACPX Claude target", () => { expect(buildClaudeAcpConfig({ engine: "acp", diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 019e06baa6..d26c48d7e4 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -53,7 +53,7 @@ import { buildLocalAdapterTestProbeEnv } from "./probe-env.js"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; import { buildClaudeProbePermissionArgs } from "./permissions.js"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js"; -import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const packageRootDir = path.resolve(moduleDir, "../.."); @@ -127,7 +127,12 @@ function firstNonEmptyString(...values: unknown[]): string | undefined { return undefined; } -export function buildClaudeAcpConfig(config: Record): Record { +export function buildClaudeAcpConfig( + config: Record, + inheritedEnv: Record = {}, +): Record { + const env = parseObject(config.env); + const model = resolveClaudeModel(config.model, { ...inheritedEnv, ...env }); const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir); const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; @@ -144,6 +149,9 @@ export function buildClaudeAcpConfig(config: Record): Record { afterEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); resetClaudeCliCapabilitiesCacheForTests(); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); @@ -94,6 +95,8 @@ describe("claude remote execution", () => { }); it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => { + vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1"); + vi.stubEnv("ANTHROPIC_MODEL", "host-only-model"); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); @@ -188,6 +191,7 @@ describe("claude remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; + expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"])); expect(call?.[2]).toContain("--allowedTools"); expect(call?.[2]).toContain( "Task AskUserQuestion Bash CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 0f24419c9f..e875157464 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -92,7 +92,7 @@ import { resolveClaudeDesiredSkillNames } from "./skills.js"; import { isBedrockModelId } from "./models.js"; import { prepareClaudePromptBundle } from "./prompt-cache.js"; import { buildClaudeExecutionPermissionArgs } from "./permissions.js"; -import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js"; import { createClaudeAcpExecutor, formatClaudeAcpFallbackMessage, @@ -431,7 +431,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); + const modelEnv = executionTargetIsRemote ? env : effectiveEnv; + const model = resolveClaudeModel(config.model, modelEnv); const billingType = resolveClaudeBillingType(effectiveEnv); const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries)); @@ -870,7 +871,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + it.each([ + [undefined, "claude-opus-5"], + ["", "claude-opus-5"], + [" ", "claude-opus-5"], + ["claude-sonnet-4-5", "claude-sonnet-4-5"], + ])("passes the resolved model to the CLI for %j", async (model, expected) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + try { + const result = await execute({ + runId: "run-default", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + engine: "cli", model, command: commandPath, cwd: workspace, + env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, + }, + context: {}, onLog: async () => {}, + }); + expect(result.exitCode).toBe(0); + const { argv } = JSON.parse(await fs.readFile(capturePath, "utf8")); + expect(argv[argv.indexOf("--model") + 1]).toBe(expected); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a strict per-agent MCP config only when managed servers are present", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-mcp-config-")); const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index bdc8f27ca9..2d7190c56b 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -872,6 +872,17 @@ describe("AgentConfigForm environment selector", () => { }); }); + it("names the Claude default for new and existing agents without pinning it", async () => { + const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })]; + const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} }); + roots.push(existing.root); + const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" }); + roots.push(created.root); + expect(existing.container.textContent).toContain("Default (claude-opus-5)"); + expect(created.container.textContent).toContain("Default (claude-opus-5)"); + expect(existing.onSave).not.toHaveBeenCalled(); + }); + it("keeps secret access out of the main Configuration content", async () => { const result = await renderForm([ makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ee76300c6..921e6c98d2 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -24,6 +24,7 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; +import { DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; @@ -1662,6 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} open={modelOpen} onOpenChange={setModelOpen} + defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined} allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local"} required={adapterType === "opencode_local" || adapterType === "pi_local"} groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}