fix(runner): acknowledge Claude cold session resume

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 11:42:37 -05:00
1 parent 5f0b898e9e
commit 5a60820588
6 files changed
+46 -8

No files matched your search

@@ -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");
@@ -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 {
@@ -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"],
@@ -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) } }
: {}),
+3 -3
View File
@@ -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,
@@ -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,