mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
fix(claude-local): resume sessions across agent file working copies (#15437)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Claude CLI adapter resumes task sessions between runs. > - Each run now receives a private copy of the agent files. > - The adapter put that copy's path in the cached system instructions. > - The new run ID changed the prompt bundle even when all instructions and skills stayed the same. > - This pull request sends the current file location with each run's prompt and keeps it out of the stable bundle. > - Agents can resume unchanged task sessions and use the current working copy. ## Linked Issues or Issue Description Fixes: #15373 Refs #14420, which introduced the per-run agent directory copies. Related PRs checked: #5699 changes the fingerprint algorithm, and #12034 handles saved sessions without a bundle key. Neither fixes this working-copy path regression. ## What Changed - Keep instruction and skill contents in the cached system prompt. Supply the current instruction path and relative-file base in every run prompt, including resumed turns and fresh retries. - Report a cwd or execution-target mismatch only when that value differs. A bundle mismatch no longer produces a false cwd warning. - Add a four-run regression: initial run, relocated copy, changed instructions, and changed skill contents. Check the CLI arguments, bundle keys, current file guidance, and reset logs. - Cover and explain remote-to-local execution resets, even when the working directory matches. - Update the agent-file documentation and existing resume/fallback assertions. ## Verification - **Red:** With only the new regression test added, the second run fails because the CLI arguments do not contain `--resume`. - **Green:** All 64 tests pass in the command below. This uses a fake Claude subprocess and real adapter execution, file caching, and session serialization; it does not call a paid model. ```sh pnpm exec vitest run server/src/__tests__/claude-local-execute.test.ts packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts server/src/__tests__/adapter-session-codecs.test.ts ``` - Repository-wide `pnpm -r typecheck` and `pnpm build`: passed. The adapter typecheck, build, and all 64 focused tests also pass after the review fix. - Local `pnpm test:run` was stopped after it reported a failure in the unchanged native-session recovery database orchestration test. That test passes in isolation with PostgreSQL enabled (1 passed, 47 filtered out). The full local run did not complete; this is not a clean local full-suite result. All CI checks pass on `a71d23a39f1cc874d23a8715cf29bdea6edbb8ff`, including the full test shards. ## Risks - A session saved with the old path-bearing bundle starts fresh once after upgrade. Later runs resume when instruction and skill contents stay unchanged. - The current location now travels in the run prompt. Stable system guidance directs relative file references to that location, and each turn explicitly replaces earlier locations. - No database, API, authentication, permission, or UI change. ## Model Used OpenAI Codex (GPT-6), with reasoning, repository inspection, code execution, and test tools. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d0db8820db
commit
1ead554bd1
4 files changed
+137
-26
No files matched your search
@@ -685,8 +685,9 @@ describe("claude execute", () => {
|
||||
expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md");
|
||||
expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile);
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions");
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).toContain(
|
||||
`The above agent instructions were loaded from ${instructionsFile}. ` +
|
||||
expect(captured[1]?.appendedSystemPromptFileContents).not.toContain(instructionsFile);
|
||||
for (const prompt of prompts) expect(prompt).toContain(
|
||||
`Agent instructions for this run were loaded from ${instructionsFile}. ` +
|
||||
`Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` +
|
||||
`This base directory is authoritative for sibling instruction files such as ` +
|
||||
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`,
|
||||
@@ -1338,7 +1339,9 @@ describe("claude execute", () => {
|
||||
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
|
||||
expect(capture1.instructionsContents).toContain("You are managed instructions.");
|
||||
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
||||
expect(capture1.instructionsContents).not.toContain(instructionsPath);
|
||||
expect(capture1.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
|
||||
expect(capture2.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
|
||||
expect(capture1.skillEntries).toContain("paperclip");
|
||||
expect(capture2.argv).toContain("--resume");
|
||||
expect(getFreshSessionHandoff).not.toHaveBeenCalled();
|
||||
@@ -1362,6 +1365,81 @@ describe("claude execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes across per-run instruction copies and refreshes their location", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-working-copy-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "claude");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const skillDir = path.join(root, "skill");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(skillDir);
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "First skill version.\n");
|
||||
await writeFakeClaudeCommand(commandPath);
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "paperclip-home"));
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
|
||||
|
||||
try {
|
||||
let sessionParams: Record<string, unknown> | null = null;
|
||||
let previousBundleKey: unknown;
|
||||
let previousInstructionsPath = "";
|
||||
for (const [index, change] of ["initial", "location", "instructions", "skill"].entries()) {
|
||||
const runId = `run-${index}`;
|
||||
const instructionsPath = path.join(root, "companies", "company-1", "agents", "agent-1",
|
||||
"file-sync", "runs", runId, "live", "AGENTS.md");
|
||||
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||
await fs.writeFile(instructionsPath, index < 2 ? "First instructions.\n" : "Changed instructions.\n");
|
||||
if (change === "skill") {
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "Changed skill version.\n");
|
||||
}
|
||||
const logs: string[] = [];
|
||||
const result = await execute({
|
||||
runId,
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
||||
runtime: { sessionId: null, sessionParams, sessionDisplayId: null, taskKey: "issue-1" },
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
paperclipRuntimeSkills: [{ key: "test-skill", runtimeName: "test-skill", source: skillDir }],
|
||||
paperclipSkillSync: { desiredSkills: ["test-skill"] },
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, CLAUDE_CONFIG_DIR: path.join(root, "claude-config") },
|
||||
},
|
||||
context: { issueId: "issue-1" },
|
||||
onLog: async (_stream, chunk) => { logs.push(chunk); },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
if (change === "location") {
|
||||
expect(capture.argv).toContain("--resume");
|
||||
expect(capture.argv).toContain("11111111-1111-4111-8111-111111111111");
|
||||
expect(capture.instructionsFilePath).toBeNull();
|
||||
expect(result.sessionParams?.promptBundleKey).toBe(previousBundleKey);
|
||||
} else {
|
||||
expect(capture.argv).not.toContain("--resume");
|
||||
expect(capture.instructionsContents).toContain(index < 2 ? "First instructions." : "Changed instructions.");
|
||||
expect(result.sessionParams?.promptBundleKey).not.toBe(previousBundleKey);
|
||||
if (index > 0) {
|
||||
expect(capture.instructionsContents).not.toContain(instructionsPath);
|
||||
expect(logs.join("")).toContain("was saved for prompt bundle");
|
||||
expect(logs.join("")).not.toContain("was saved for cwd");
|
||||
}
|
||||
}
|
||||
if (index > 0) {
|
||||
expect(capture.prompt).toContain(instructionsPath);
|
||||
expect(capture.prompt).toContain(`Resolve any relative file references from ${path.dirname(instructionsPath)}/.`);
|
||||
}
|
||||
if (previousInstructionsPath) expect(capture.prompt).not.toContain(previousInstructionsPath);
|
||||
sessionParams = sessionCodec.deserialize(sessionCodec.serialize(result.sessionParams ?? null));
|
||||
previousBundleKey = result.sessionParams?.promptBundleKey;
|
||||
previousInstructionsPath = instructionsPath;
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
Reference in new issue
Block a user