mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
fix(runner): acknowledge Claude cold session resume
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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) } }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user