diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts index 6c5a9b8fcf1..e21cd094851 100644 --- a/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts @@ -5,7 +5,7 @@ const response = (value: string, sessionId?: string) => ({ ...(sessionId ? { ses function loaded(method = "session/new") { const admission = createClaudeEffortAdmission("high"); const guard = admission.createGuard(); - guard("outbound", { id: 1, method, params: method === "session/load" ? { sessionId: "s" } : {} }); + guard("outbound", { id: 1, method, params: method === "session/new" ? {} : { sessionId: "s" } }); guard("inbound", { id: 1, result: response("medium", method === "session/new" ? "s" : undefined) }); return { admission, guard }; } @@ -23,17 +23,37 @@ describe("Claude effort admission", () => { expect(() => resolveClaudeEffort("pi", "high")).toThrow("only supported"); expect(() => admittedClaudeEffort("claude", "high", { claudeEffort: "high" }, { claudeEffort: "medium" })).toThrow("differs"); }); - it.each(["session/new", "session/load"])("requires acknowledged effort before prompting after %s", method => { + it.each(["session/new", "session/load", "session/resume"])("requires acknowledged effort before prompting after %s", method => { const { admission, guard } = loaded(method); expect(admission.isReady()).toBe(false); select(guard); expect(admission.isReady()).toBe(true); expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); }); + + it("rejects a resumed session with missing effort configuration", () => { + const admission = createClaudeEffortAdmission("high"); + const guard = admission.createGuard(); + guard("outbound", { id: 1, method: "session/resume", params: { sessionId: "s" } }); + expect(() => guard("inbound", { id: 1, result: {} })).toThrow("missing or ambiguous"); + }); it("blocks a prompt based only on a restored default", () => { const { guard } = loaded("session/load"); expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); }); + + it("requires the bound effort acknowledgement on each cold connection", () => { + const { admission, guard } = loaded(); + select(guard); + const resumed = admission.createGuard(); + resumed("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + expect(() => resumed("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); + const fresh = loaded(); select(fresh.guard); + const acknowledged = fresh.admission.createGuard(); + acknowledged("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + acknowledged("inbound", { id: 4, result: response("high") }); + expect(() => acknowledged("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); + }); it("rejects a provider that ignores the requested effort", () => { const { guard } = loaded(); expect(() => select(guard, "medium")).toThrow("did not apply"); diff --git a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts index dfe8806ae2a..be597f10e16 100644 --- a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -41,11 +41,11 @@ export function createAcpxConfigAdmission(expected: string, options: { label: st if (method === "session/prompt") { assertReady(); if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); - } else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") { + } else if (method === "session/new" || method === "session/load" || method === "session/resume" || method === "session/set_config_option") { if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); if (pending.has(message.id)) throw failure("duplicate mode request identity"); if (pending.size) throw failure("overlapping native admission controls"); - if (method === "session/new" || method === "session/load") { + if (method === "session/new" || method === "session/load" || method === "session/resume") { if (pending.size) throw failure("overlapping session admission"); state.sessionId = null; state.mode = null; } else { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index ab14aa666f3..7225231f2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -119,6 +119,20 @@ describe("ACPX runtime sandbox", () => { } }); + it("restores bound Claude effort in private settings across cold connections", async () => { + const fixture = await sandboxFixture("claude"); + for (const claudeEffort of ["high", "xhigh", "max"] as const) { + const binding = { ...fixture.binding, claudeEffort }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); + expect(settings.effortLevel).toBe(claudeEffort); + } + } + const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "claude" }); + expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"))).not.toHaveProperty("effortLevel"); + }); + it.each([ ["pi", "OPENROUTER_API_KEY", "pi-home"], ["claude", "ANTHROPIC_API_KEY", "claude-home"], diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 3e0e52d250d..d5ae8ce9e14 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -403,6 +403,10 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], + // Claude reloads effort from settings on a new ACP connection. Keep + // the bound choice available during model verification and resume; + // the protocol guard still requires a provider acknowledgement. + ...(input.binding.claudeEffort ? { effortLevel: input.binding.claudeEffort } : {}), ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } : {}), diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 5789821b052..bf6a6dcd746 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -501,19 +501,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { models: [ ...codexModels, { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], listModels: async () => [ ...await listCodexModels(), { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], refreshModels: async () => [ ...await refreshCodexModels(), { id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" }, - { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" }, + { id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" }, { id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" }, ], supportsLocalAgentJwt: false, diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 804b983940a..5e5b41b940c 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -696,7 +696,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele }); getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff; const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server" - || (nativeRuntimeResolution.profile.backend === "acpx_runtime" && nativeRuntimeResolution.profile.provider === "acpx" && nativeRuntimeResolution.profile.acpxAgent === "claude") + || (nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).provider === "acpx" && parseObject(agent.adapterConfig).acpxAgent === "claude") || nativeRuntimeResolution.profile.backend === "opencode_server" ? projectPaperclipRunnerTaskConfig( nativeRuntimeResolution.profile.backend,