diff --git a/doc/agent-files.md b/doc/agent-files.md index a4154551f1..f037cb2551 100644 --- a/doc/agent-files.md +++ b/doc/agent-files.md @@ -37,6 +37,12 @@ workspace: their independent agent copy therefore lives under the excluded `.paperclip-runtime/agent-files///` area. It is not included in task workspace sync, Git staging, or task deliverables. +Claude CLI runs keep the working-copy location in each run's prompt, separate +from the cached system instructions. A new copy path alone does not reset the +task session. Each turn names the current copy for relative file references; +instruction or enabled skill content changes still invalidate the prompt bundle. +Sessions saved with the older path-bearing bundle start fresh once after upgrade. + Regular files (including binary bytes) and directories are supported, up to 100,000 entries (files and folders), 256 MiB per file and 2 GiB total. Symlinks, hardlinks, and special diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index 9ccc3ef77e..cbb6ed5501 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -285,6 +285,34 @@ describe("claude remote execution", () => { expect(call?.[2]).not.toContain("--resume"); }); + it("explains a remote-to-local session reset even when the cwd matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-local-reset-")); + cleanupDirs.push(rootDir); + vi.stubEnv("PAPERCLIP_HOME", rootDir); + const onLog = vi.fn(async () => {}); + + await execute({ + runId: "run-local-reset", + agent: { id: "agent-1", companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", adapterConfig: {} }, + runtime: { + sessionId: "12345678-1234-4abc-9def-123456789012", + sessionParams: { + cwd: rootDir, + remoteExecution: { transport: "ssh", host: "remote.test", port: 22, username: "fixture", remoteCwd: rootDir }, + }, + sessionDisplayId: null, + taskKey: null, + }, + config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] }, + context: {}, + onLog, + }); + + expect(runChildProcess.mock.calls[0]?.[2]).not.toContain("--resume"); + expect(onLog).toHaveBeenCalledWith("stdout", expect.stringContaining("does not match the current execution target")); + expect(onLog).not.toHaveBeenCalledWith("stdout", expect.stringContaining("was saved for cwd")); + }); + it("resumes saved Claude sessions for remote SSH execution when the remote identity matches", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ee1f3e35a7..de03c588c0 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -499,19 +499,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && isValidUuid && hasMatchingPromptBundle && // Each CLI invocation loads --mcp-config, including --resume invocations. // Refreshing tools does not invalidate the saved conversation. - claudeSessionCwdMatchesExecutionTarget({ - runtimeSessionCwd, - effectiveExecutionCwd, - executionTargetIsRemote, - }) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + hasMatchingSessionCwd && + hasMatchingExecutionTarget; const sessionId = canResumeSession ? runtimeSessionId : null; if (runtimeSessionId && !isValidUuid) { await onLog( @@ -798,26 +802,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && - path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd) + !hasMatchingSessionCwd ) { - await onLog( - "stdout", - `[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, - ); - } else if (runtimeSessionId && isValidUuid && !canResumeSession) { await onLog( "stdout", `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, @@ -916,6 +914,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index b45af79f0a..adea792ca6 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -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 | 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");