mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## 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>
1923 lines
77 KiB
TypeScript
1923 lines
77 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AdapterRuntimeMcpServer } from "@paperclipai/adapter-utils";
|
|
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
|
import { buildPaperclipTaskMarkdown } from "../services/heartbeat.js";
|
|
import {
|
|
claudeCommandSupportsEffortFlag,
|
|
claudeSessionCwdMatchesExecutionTarget,
|
|
execute,
|
|
resetClaudeCliCapabilitiesCacheForTests,
|
|
sessionCodec,
|
|
} from "@paperclipai/adapter-claude-local/server";
|
|
|
|
async function writeFailingClaudeCommand(
|
|
commandPath: string,
|
|
options: { resultEvent: Record<string, unknown>; exitCode?: number },
|
|
): Promise<void> {
|
|
const payload = JSON.stringify(options.resultEvent);
|
|
const exit = options.exitCode ?? 1;
|
|
const script = `#!/usr/bin/env node
|
|
console.log(${JSON.stringify(payload)});
|
|
process.exit(${exit});
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeTextFailingClaudeCommand(
|
|
commandPath: string,
|
|
options: { stdout?: string; stderr?: string; exitCode?: number },
|
|
): Promise<void> {
|
|
const exit = options.exitCode ?? 1;
|
|
const script = `#!/usr/bin/env node
|
|
if (${JSON.stringify(options.stdout ?? "")}) {
|
|
process.stdout.write(${JSON.stringify(options.stdout ?? "")});
|
|
}
|
|
if (${JSON.stringify(options.stderr ?? "")}) {
|
|
process.stderr.write(${JSON.stringify(options.stderr ?? "")});
|
|
}
|
|
process.exit(${exit});
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const argv = process.argv.slice(2);
|
|
const addDirIndex = argv.indexOf("--add-dir");
|
|
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
|
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
|
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
|
const mcpConfigIndex = argv.indexOf("--mcp-config");
|
|
const mcpConfigPath = mcpConfigIndex >= 0 ? argv[mcpConfigIndex + 1] : null;
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const payload = {
|
|
argv,
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
addDir,
|
|
instructionsFilePath,
|
|
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
|
mcpConfigPath,
|
|
mcpConfigContents: mcpConfigPath ? fs.readFileSync(mcpConfigPath, "utf8") : null,
|
|
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
|
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
|
claudeConfigEntries: process.env.CLAUDE_CONFIG_DIR && fs.existsSync(process.env.CLAUDE_CONFIG_DIR)
|
|
? fs.readdirSync(process.env.CLAUDE_CONFIG_DIR).sort()
|
|
: [],
|
|
paperclipApiUrl: process.env.PAPERCLIP_API_URL || null,
|
|
paperclipApiKey: process.env.PAPERCLIP_API_KEY || null,
|
|
paperclipApiBridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE || null,
|
|
};
|
|
if (capturePath) {
|
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "11111111-1111-4111-8111-111111111111", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "11111111-1111-4111-8111-111111111111", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-8111-111111111111", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const argv = process.argv.slice(2);
|
|
if (argv.includes("--help")) {
|
|
process.stdout.write("Usage: claude [options]\\n --print\\n --model <id>\\n");
|
|
process.exit(0);
|
|
}
|
|
if (argv.includes("--effort")) {
|
|
process.stderr.write("error: unknown option '--effort'\\n");
|
|
process.exit(1);
|
|
}
|
|
const addDirIndex = argv.indexOf("--add-dir");
|
|
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
|
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
|
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const payload = {
|
|
argv,
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
addDir,
|
|
instructionsFilePath,
|
|
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
|
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
|
};
|
|
if (capturePath) {
|
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "33333333-3333-4333-8333-333333333333", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "33333333-3333-4333-8333-333333333333", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-8333-333333333333", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const argv = process.argv.slice(2);
|
|
if (argv.includes("--help")) {
|
|
const helpCountPath = process.env.PAPERCLIP_TEST_HELP_COUNT_PATH;
|
|
if (helpCountPath) {
|
|
const current = fs.existsSync(helpCountPath) ? Number(fs.readFileSync(helpCountPath, "utf8")) || 0 : 0;
|
|
fs.writeFileSync(helpCountPath, String(current + 1), "utf8");
|
|
}
|
|
process.stdout.write("Usage: claude [options]\\n --print\\n --effort <level>\\n --model <id>\\n");
|
|
process.exit(0);
|
|
}
|
|
const addDirIndex = argv.indexOf("--add-dir");
|
|
const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
|
|
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
|
|
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const payload = {
|
|
argv,
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
addDir,
|
|
instructionsFilePath,
|
|
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
|
|
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
|
|
};
|
|
if (capturePath) {
|
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "44444444-4444-4444-8444-444444444444", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "44444444-4444-4444-8444-444444444444", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "44444444-4444-4444-8444-444444444444", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
type CapturePayload = {
|
|
argv: string[];
|
|
prompt: string;
|
|
addDir: string | null;
|
|
instructionsFilePath: string | null;
|
|
instructionsContents: string | null;
|
|
skillEntries: string[];
|
|
claudeConfigDir: string | null;
|
|
claudeConfigEntries?: string[];
|
|
paperclipApiUrl?: string | null;
|
|
paperclipApiKey?: string | null;
|
|
paperclipApiBridgeMode?: string | null;
|
|
appendedSystemPromptFilePath?: string | null;
|
|
appendedSystemPromptFileContents?: string | null;
|
|
};
|
|
|
|
afterEach(() => {
|
|
resetClaudeCliCapabilitiesCacheForTests();
|
|
});
|
|
|
|
async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const statePath = process.env.PAPERCLIP_TEST_STATE_PATH;
|
|
const payload = {
|
|
argv: process.argv.slice(2),
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
};
|
|
if (capturePath) {
|
|
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
|
entries.push(payload);
|
|
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
|
}
|
|
const resumed = process.argv.includes("--resume");
|
|
const shouldFailResume = resumed && statePath && !fs.existsSync(statePath);
|
|
if (shouldFailResume) {
|
|
fs.writeFileSync(statePath, "retried", "utf8");
|
|
console.log(JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
session_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
is_error: true,
|
|
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const payload = {
|
|
argv: process.argv.slice(2),
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
};
|
|
if (capturePath) {
|
|
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
|
entries.push(payload);
|
|
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
|
}
|
|
// Both --resume and fresh attempts emit the poisoned previous_message_id result.
|
|
// The fresh attempt still carries a session_id in the result; the adapter must
|
|
// NOT persist it, otherwise the next continuation re-resumes a known-bad transcript.
|
|
console.log(JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
session_id: "fffff111-0000-4000-8000-000000000003",
|
|
is_error: true,
|
|
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
|
}));
|
|
process.exit(1);
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
|
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
|
const statePath = process.env.PAPERCLIP_TEST_STATE_PATH;
|
|
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
|
|
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
|
|
const payload = {
|
|
argv: process.argv.slice(2),
|
|
prompt: fs.readFileSync(0, "utf8"),
|
|
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
|
appendedSystemPromptFilePath,
|
|
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
|
|
};
|
|
if (capturePath) {
|
|
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
|
entries.push(payload);
|
|
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
|
}
|
|
const resumed = process.argv.includes("--resume");
|
|
const shouldFailResume = resumed && statePath && !fs.existsSync(statePath);
|
|
if (shouldFailResume) {
|
|
fs.writeFileSync(statePath, "retried", "utf8");
|
|
console.log(JSON.stringify({
|
|
type: "result",
|
|
subtype: "error",
|
|
session_id: "11111111-1111-4111-8111-111111111111",
|
|
result: "No conversation found with session id 11111111-1111-4111-8111-111111111111",
|
|
errors: ["No conversation found with session id 11111111-1111-4111-8111-111111111111"],
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "22222222-2222-4222-8222-222222222222", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "22222222-2222-4222-8222-222222222222", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "22222222-2222-4222-8222-222222222222", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function setupExecuteEnv(
|
|
root: string,
|
|
options?: { commandWriter?: (commandPath: string) => Promise<void> },
|
|
) {
|
|
const workspace = path.join(root, "workspace");
|
|
const binDir = path.join(root, "bin");
|
|
const commandPath = path.join(binDir, "claude");
|
|
const capturePath = path.join(root, "capture.json");
|
|
const statePath = path.join(root, "state.txt");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
await (options?.commandWriter ?? writeFakeClaudeCommand)(commandPath);
|
|
const previousHome = process.env.HOME;
|
|
const previousPath = process.env.PATH;
|
|
process.env.HOME = root;
|
|
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
return {
|
|
workspace, commandPath, capturePath, statePath,
|
|
restore: () => {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createLocalSandboxRunner() {
|
|
let counter = 0;
|
|
return {
|
|
execute: async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
}) => {
|
|
counter += 1;
|
|
return runChildProcess(
|
|
`sandbox-run-${counter}`,
|
|
input.command,
|
|
input.args ?? [],
|
|
{
|
|
cwd: input.cwd ?? process.cwd(),
|
|
env: input.env ?? {},
|
|
stdin: input.stdin,
|
|
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
graceSec: 5,
|
|
onLog: input.onLog ?? (async () => {}),
|
|
onSpawn: input.onSpawn
|
|
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
|
: undefined,
|
|
},
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("claude execute", () => {
|
|
it("passes real assignment markdown and ordered current events once through the CLI", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-context-owner-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
|
const markdown = buildPaperclipTaskMarkdown({
|
|
issue: { id: "issue-1", identifier: "PAP-901", title: "Repeat phrase Repeat phrase", description: "Repeat phrase Repeat phrase" },
|
|
wakeComments: [
|
|
{ id: "comment-a", body: "Same event body." },
|
|
{ id: "comment-b", body: "Same event body." },
|
|
],
|
|
includeWakeComments: false,
|
|
});
|
|
const historicalMarkdown = buildPaperclipTaskMarkdown({
|
|
issue: { id: "issue-1", identifier: "PAP-901", title: "Repeat phrase Repeat phrase", description: "Repeat phrase Repeat phrase" },
|
|
wakeComments: [
|
|
{ id: "comment-a", body: "Same event body." },
|
|
{ id: "comment-b", body: "Same event body." },
|
|
],
|
|
});
|
|
try {
|
|
await execute({
|
|
runId: "run-context-owner",
|
|
agent: { id: "agent-1", companyId: "company-1", name: "Claude", adapterType: "claude_local", adapterConfig: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli", command: commandPath, cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Custom template keeps {{paperclipTaskMarkdown}} and {{paperclipWakePrompt}}.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipTaskMarkdown: historicalMarkdown,
|
|
paperclipTaskMarkdownAssignment: markdown,
|
|
paperclipWake: {
|
|
reason: "issue_commented",
|
|
issue: { id: "issue-1", identifier: "PAP-901", title: "Repeat phrase Repeat phrase", description: "Repeat phrase Repeat phrase", status: "in_progress" },
|
|
comments: [
|
|
{ id: "comment-a", issueId: "issue-1", body: "Same event body.", bodyTruncated: false, createdAt: "2026-09-21T00:00:00.000Z" },
|
|
{ id: "comment-b", issueId: "issue-1", body: "Same event body.", bodyTruncated: false, createdAt: "2026-09-21T00:01:00.000Z" },
|
|
],
|
|
commentWindow: { requestedCount: 2, includedCount: 2, missingCount: 0 },
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
paperclipTurnContext: { version: 1, assignment: { owner: "task_markdown" }, events: { owner: "wake_prompt", comments: [{ id: "comment-a", revision: "a" }, { id: "comment-b", revision: "b" }] } },
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
|
expect(capture.prompt).toContain("Custom template keeps");
|
|
expect(capture.prompt.indexOf("comment-a")).toBeLessThan(capture.prompt.indexOf("comment-b"));
|
|
expect(capture.prompt.split("Same event body.")).toHaveLength(3);
|
|
expect(capture.prompt.split("Repeat phrase Repeat phrase")).toHaveLength(4);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[undefined, "claude-opus-5"],
|
|
["", "claude-opus-5"],
|
|
[" ", "claude-opus-5"],
|
|
["claude-sonnet-4-5", "claude-sonnet-4-5"],
|
|
])("passes the resolved model to the CLI for %j", async (model, expected) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-default",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli", model, command: commandPath, cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
},
|
|
context: {}, onLog: async () => {},
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
const { argv } = JSON.parse(await fs.readFile(capturePath, "utf8"));
|
|
expect(argv[argv.indexOf("--model") + 1]).toBe(expected);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses a strict per-agent MCP config only when managed servers are present", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-mcp-config-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
|
try {
|
|
const run = async (runId: string, agentId: string, servers: AdapterRuntimeMcpServer[]) => {
|
|
await execute({
|
|
runId,
|
|
agent: { id: agentId, companyId: "co-1", name: agentId, adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Do work.",
|
|
},
|
|
runtimeMcp: { getServers: () => servers },
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
return JSON.parse(await fs.readFile(capturePath, "utf8"));
|
|
};
|
|
|
|
const alpha = await run("run-alpha", "agent-alpha", [{
|
|
name: "alpha",
|
|
url: "https://paperclip.example/api/tool-gateway/gateways/alpha/mcp",
|
|
token: "alpha-token",
|
|
connectionId: "connection-alpha",
|
|
}]);
|
|
const zero = await run("run-zero", "agent-zero", []);
|
|
|
|
expect(alpha.argv).toEqual(expect.arrayContaining(["--strict-mcp-config", "--mcp-config"]));
|
|
expect(JSON.parse(alpha.mcpConfigContents)).toEqual({
|
|
mcpServers: {
|
|
alpha: {
|
|
type: "http",
|
|
url: "https://paperclip.example/api/tool-gateway/gateways/alpha/mcp",
|
|
headers: { Authorization: "Bearer alpha-token" },
|
|
},
|
|
},
|
|
});
|
|
expect(zero.argv).not.toContain("--mcp-config");
|
|
expect(zero.argv).not.toContain("--strict-mcp-config");
|
|
expect(zero.mcpConfigPath).toBeNull();
|
|
expect(zero.mcpConfigContents).toBeNull();
|
|
expect(alpha.mcpConfigPath).toContain("/agents/agent-alpha/");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Regression tests for https://github.com/paperclipai/paperclip/issues/2848
|
|
*
|
|
* --append-system-prompt-file should only be passed on fresh sessions.
|
|
* On resumed sessions the instructions are already in the session cache;
|
|
* re-injecting them wastes tokens and may be rejected by the CLI.
|
|
*/
|
|
it("passes --append-system-prompt-file on a fresh session when instructionsFile is set", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-fresh-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
|
const instructionsFile = path.join(root, "instructions.md");
|
|
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
|
try {
|
|
await execute({
|
|
runId: "run-fresh",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Do work.",
|
|
instructionsFilePath: instructionsFile,
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
});
|
|
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
|
expect(captured.argv).toContain("--print");
|
|
expect(captured.argv).not.toContain("-");
|
|
expect(captured.prompt).toContain("Do work.");
|
|
expect(captured.argv).toContain("--append-system-prompt-file");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("omits --append-system-prompt-file on a resumed session even when instructionsFile is set", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
|
|
const instructionsFile = path.join(root, "instructions.md");
|
|
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
|
try {
|
|
await execute({
|
|
runId: "run-resume",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Do work.",
|
|
instructionsFilePath: instructionsFile,
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
});
|
|
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
|
expect(captured.argv).not.toContain("--append-system-prompt-file");
|
|
expect(captured.argv).toContain("--resume");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Regression tests for commandNotes accuracy (Greptile P2).
|
|
*
|
|
* commandNotes should only claim instructions were injected when the flag
|
|
* was actually passed — i.e. on fresh sessions, not resumed ones.
|
|
*/
|
|
it("commandNotes reports injection on a fresh session with instructionsFile", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-fresh-"));
|
|
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
|
const instructionsFile = path.join(root, "instructions.md");
|
|
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
|
let capturedNotes: string[] = [];
|
|
try {
|
|
await execute({
|
|
runId: "run-notes-fresh",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: {},
|
|
promptTemplate: "Do work.",
|
|
instructionsFilePath: instructionsFile,
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; },
|
|
});
|
|
expect(capturedNotes.some((n) => n.includes("--append-system-prompt-file"))).toBe(true);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("commandNotes is empty on a resumed session even when instructionsFile is set", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-resume-"));
|
|
const { workspace, commandPath, restore } = await setupExecuteEnv(root);
|
|
const instructionsFile = path.join(root, "instructions.md");
|
|
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
|
let capturedNotes: string[] = ["sentinel"];
|
|
try {
|
|
await execute({
|
|
runId: "run-notes-resume",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: {},
|
|
promptTemplate: "Do work.",
|
|
instructionsFilePath: instructionsFile,
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; },
|
|
});
|
|
expect(capturedNotes).toHaveLength(0);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-"));
|
|
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writeRetryThenSucceedClaudeCommand,
|
|
});
|
|
const instructionsFile = path.join(root, "instructions.md");
|
|
await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8");
|
|
const metaEvents: Array<{ commandArgs: string[]; commandNotes: string[] }> = [];
|
|
const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF: original goal and prior decisions");
|
|
const prompts: string[] = [];
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-resume-fallback",
|
|
getFreshSessionHandoff,
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
|
PAPERCLIP_TEST_STATE_PATH: statePath,
|
|
},
|
|
promptTemplate: "Do work.",
|
|
instructionsFilePath: instructionsFile,
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
onMeta: async (meta) => {
|
|
prompts.push(String(meta.prompt ?? ""));
|
|
metaEvents.push({
|
|
commandArgs: ((meta.commandArgs as string[]) ?? []).slice(),
|
|
commandNotes: ((meta.commandNotes as string[]) ?? []).slice(),
|
|
});
|
|
},
|
|
});
|
|
const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")) as Array<{
|
|
argv: string[];
|
|
appendedSystemPromptFilePath: string | null;
|
|
appendedSystemPromptFileContents: string | null;
|
|
}>;
|
|
expect(captured).toHaveLength(2);
|
|
expect(getFreshSessionHandoff).toHaveBeenCalledOnce();
|
|
expect(prompts[0]).not.toContain("FRESH_HANDOFF");
|
|
expect(prompts[1]).toContain("FRESH_HANDOFF: original goal and prior decisions");
|
|
expect(captured[0]?.argv).toContain("--resume");
|
|
expect(captured[0]?.argv).not.toContain("--append-system-prompt-file");
|
|
expect(captured[1]?.argv).not.toContain("--resume");
|
|
expect(captured[1]?.argv).toContain("--append-system-prompt-file");
|
|
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).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.`,
|
|
);
|
|
expect(metaEvents).toHaveLength(2);
|
|
expect(metaEvents[0]?.commandNotes).toHaveLength(0);
|
|
expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true);
|
|
expect(result.sessionId).toBe("22222222-2222-4222-8222-222222222222");
|
|
expect(result.clearSession).toBe(false);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("normalizes max-turn exhaustion into scheduler stop metadata", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turns-"));
|
|
const resultEvent = {
|
|
type: "result",
|
|
subtype: "error_max_turns",
|
|
session_id: "11111111-1111-4111-8111-111111111111",
|
|
is_error: true,
|
|
result: "Maximum turns reached.",
|
|
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
|
};
|
|
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
|
|
});
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-max-turns",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).toBe("max_turns_exhausted");
|
|
expect(result.errorFamily).toBeNull();
|
|
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
|
|
expect(result.clearSession).toBe(true);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not normalize unstructured max-turn text into scheduler stop metadata", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-text-"));
|
|
const resultEvent = {
|
|
type: "result",
|
|
subtype: "error",
|
|
session_id: "11111111-1111-4111-8111-111111111111",
|
|
is_error: true,
|
|
result: "Tool output said: Maximum turns reached.",
|
|
};
|
|
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }),
|
|
});
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-max-turns-text",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).not.toBe("max_turns_exhausted");
|
|
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
|
|
expect(result.clearSession).toBe(false);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not normalize fallback stdout/stderr max-turn text into scheduler stop metadata", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-fallback-"));
|
|
const { workspace, commandPath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: (commandPath) =>
|
|
writeTextFailingClaudeCommand(commandPath, {
|
|
stdout: "attacker-controlled tool output: max turns exhausted\n",
|
|
stderr: "Maximum turns reached.\n",
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-max-turns-fallback-text",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).not.toBe("max_turns_exhausted");
|
|
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
|
|
expect(result.clearSession).toBe(false);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const binDir = path.join(root, "bin");
|
|
const commandPath = path.join(binDir, "claude");
|
|
const capturePath = path.join(root, "capture.json");
|
|
const claudeConfigDir = path.join(root, "claude-config");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
await fs.mkdir(claudeConfigDir, { recursive: true });
|
|
await writeFakeClaudeCommand(commandPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
const previousPath = process.env.PATH;
|
|
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
process.env.HOME = root;
|
|
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
|
|
|
let loggedCommand: string | null = null;
|
|
let loggedEnv: Record<string, string> = {};
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-meta",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: "claude",
|
|
cwd: workspace,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
onMeta: async (meta) => {
|
|
loggedCommand = meta.command;
|
|
loggedEnv = meta.env ?? {};
|
|
},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.errorMessage).toBeNull();
|
|
expect(result.usage).toEqual({ inputTokens: 1, cachedInputTokens: 0, outputTokens: 1 });
|
|
expect(result.usageBasis).toBe("per_run");
|
|
expect(result.costUsd).toBeNull();
|
|
expect(loggedCommand).toBe(commandPath);
|
|
expect(loggedEnv.HOME).toBe(root);
|
|
expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir);
|
|
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath);
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("injects bridge env into sandbox-managed remote runs", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-"));
|
|
const localWorkspace = path.join(root, "workspace");
|
|
const remoteWorkspace = path.join(root, "sandbox-$HOME");
|
|
const binDir = path.join(root, "bin");
|
|
const commandPath = path.join(binDir, "claude");
|
|
const capturePath1 = path.join(remoteWorkspace, "capture-1.json");
|
|
const claudeRoot = path.join(root, ".claude");
|
|
const previousHome = process.env.HOME;
|
|
const previousPath = process.env.PATH;
|
|
|
|
await fs.mkdir(localWorkspace, { recursive: true });
|
|
await fs.mkdir(remoteWorkspace, { recursive: true });
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
await fs.mkdir(claudeRoot, { recursive: true });
|
|
await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8");
|
|
await writeFakeClaudeCommand(commandPath);
|
|
|
|
process.env.HOME = root;
|
|
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-sandbox-auth",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: localWorkspace,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: remoteWorkspace,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.sessionParams).toMatchObject({
|
|
cwd: localWorkspace,
|
|
remoteExecution: {
|
|
transport: "sandbox",
|
|
providerKey: "e2b",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: remoteWorkspace,
|
|
},
|
|
});
|
|
const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
|
expect(capture.argv).toContain("--dangerously-skip-permissions");
|
|
expect(capture.argv).not.toContain("--allowedTools");
|
|
expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config"));
|
|
expect(capture.claudeConfigEntries).toContain("settings.json");
|
|
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
|
|
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = previousPath;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 10_000);
|
|
|
|
it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writeHelpWithoutEffortClaudeCommand,
|
|
});
|
|
const remoteWorkspace = path.join(root, "sandbox-workspace");
|
|
await fs.mkdir(remoteWorkspace, { recursive: true });
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-sandbox-effort-fallback",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
effort: "low",
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
|
},
|
|
promptTemplate: "Fallback cleanly if the sandbox CLI is old.",
|
|
},
|
|
context: {},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "daytona",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: remoteWorkspace,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
|
expect(capture.argv).not.toContain("--effort");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 10_000);
|
|
|
|
it("passes through --effort and reuses the sandbox capability probe across sandbox leases when the installed Claude CLI advertises it", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-supported-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writeHelpWithEffortClaudeCommand,
|
|
});
|
|
const helpCountPath = path.join(root, "help-count.txt");
|
|
const remoteWorkspace = path.join(root, "sandbox-workspace");
|
|
await fs.mkdir(remoteWorkspace, { recursive: true });
|
|
|
|
const baseInput = {
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
effort: "low",
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
|
PAPERCLIP_TEST_HELP_COUNT_PATH: helpCountPath,
|
|
},
|
|
promptTemplate: "Keep the requested effort when supported.",
|
|
},
|
|
context: {},
|
|
executionTarget: {
|
|
kind: "remote" as const,
|
|
transport: "sandbox" as const,
|
|
providerKey: "daytona",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: remoteWorkspace,
|
|
timeoutMs: 30_000,
|
|
runner: createLocalSandboxRunner(),
|
|
},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
};
|
|
|
|
try {
|
|
const first = await execute({
|
|
runId: "run-sandbox-effort-supported-1",
|
|
...baseInput,
|
|
});
|
|
const second = await execute({
|
|
runId: "run-sandbox-effort-supported-2",
|
|
...baseInput,
|
|
executionTarget: {
|
|
...baseInput.executionTarget,
|
|
leaseId: "lease-2",
|
|
},
|
|
});
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
|
expect(capture.argv).toContain("--effort");
|
|
expect(capture.argv).toContain("low");
|
|
expect(await fs.readFile(helpCountPath, "utf8")).toBe("1");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 10_000);
|
|
|
|
it("degrades to the conservative fallback (returns null) when the sandbox probe throws, and retries on the next lease", async () => {
|
|
let calls = 0;
|
|
const throwingRunner = {
|
|
execute: async () => {
|
|
calls += 1;
|
|
throw new Error("sandbox connection error");
|
|
},
|
|
};
|
|
const target = {
|
|
kind: "remote" as const,
|
|
transport: "sandbox" as const,
|
|
providerKey: "daytona",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: "/remote/workspace",
|
|
timeoutMs: 30_000,
|
|
runner: throwingRunner,
|
|
};
|
|
const probeInput = {
|
|
runId: "run-probe-throws",
|
|
command: "/usr/local/bin/claude",
|
|
cwd: "/host/workspace",
|
|
env: {},
|
|
timeoutSec: 20,
|
|
graceSec: 5,
|
|
};
|
|
|
|
// A thrown probe must resolve to null (unknown) rather than reject and kill the run.
|
|
await expect(
|
|
claudeCommandSupportsEffortFlag({ ...probeInput, target }),
|
|
).resolves.toBeNull();
|
|
|
|
// The failed result is not cached: a second lease re-probes instead of reusing the rejection.
|
|
await expect(
|
|
claudeCommandSupportsEffortFlag({
|
|
...probeInput,
|
|
target: { ...target, leaseId: "lease-2" },
|
|
}),
|
|
).resolves.toBeNull();
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it("allows remote session resumes when saved cwd is the host workspace", () => {
|
|
expect(claudeSessionCwdMatchesExecutionTarget({
|
|
runtimeSessionCwd: "/host/workspace",
|
|
effectiveExecutionCwd: "/remote/workspace",
|
|
executionTargetIsRemote: true,
|
|
})).toBe(true);
|
|
expect(claudeSessionCwdMatchesExecutionTarget({
|
|
runtimeSessionCwd: "/host/workspace",
|
|
effectiveExecutionCwd: "/remote/workspace",
|
|
executionTargetIsRemote: false,
|
|
})).toBe(false);
|
|
});
|
|
|
|
it.each(["unchanged", "added", "removed"])("resumes a stable Claude prompt bundle with %s MCP tools", async (change) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "claude");
|
|
const capturePath1 = path.join(root, "capture-1.json");
|
|
const capturePath2 = path.join(root, "capture-2.json");
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
const paperclipHome = path.join(root, "paperclip-home");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
|
|
await writeFakeClaudeCommand(commandPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
|
process.env.HOME = root;
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
|
|
try {
|
|
const first = await execute({
|
|
runId: "run-1",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
paperclipSkillSync: {
|
|
desiredSkills: ["paperclip"],
|
|
},
|
|
},
|
|
context: {},
|
|
runtimeMcp: {
|
|
getServers: () => [{
|
|
name: "Paperclip projects",
|
|
url: "http://localhost:3100/api/mcp/project-tools",
|
|
connectionId: "paperclip-project-tools",
|
|
token: "run-jwt-token",
|
|
}],
|
|
},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(first.errorMessage).toBeNull();
|
|
expect(first.sessionParams).toMatchObject({
|
|
sessionId: "11111111-1111-4111-8111-111111111111",
|
|
cwd: workspace,
|
|
});
|
|
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
|
|
|
|
const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF_ONLY");
|
|
const second = await execute({
|
|
getFreshSessionHandoff,
|
|
runId: "run-2",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)),
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
paperclipSkillSync: {
|
|
desiredSkills: ["paperclip"],
|
|
},
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-2",
|
|
refreshTools: change !== "unchanged",
|
|
paperclipWake: {
|
|
reason: "issue_commented",
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-874",
|
|
title: "chat-speed issues",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
},
|
|
commentIds: ["comment-2"],
|
|
latestCommentId: "comment-2",
|
|
comments: [
|
|
{
|
|
id: "comment-2",
|
|
issueId: "issue-1",
|
|
body: "Second comment",
|
|
bodyTruncated: false,
|
|
createdAt: "2026-03-28T14:35:10.000Z",
|
|
author: { type: "user", id: "user-1" },
|
|
},
|
|
],
|
|
commentWindow: {
|
|
requestedCount: 1,
|
|
includedCount: 1,
|
|
missingCount: 0,
|
|
},
|
|
truncated: false,
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
},
|
|
runtimeMcp: {
|
|
getServers: () => change === "removed" ? [] : [{
|
|
name: "Paperclip projects",
|
|
url: "http://localhost:3100/api/mcp/project-tools",
|
|
connectionId: "paperclip-project-tools",
|
|
token: "next-run-jwt-token",
|
|
}, ...(change === "added" ? [{ name: "GitHub", url: "https://example.test/github/mcp", connectionId: "github", token: "fresh-github-token" }] : [])],
|
|
},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(second.exitCode).toBe(0);
|
|
expect(second.errorMessage).toBeNull();
|
|
|
|
const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
|
const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
|
|
const expectedRoot = path.join(
|
|
paperclipHome,
|
|
"instances",
|
|
"default",
|
|
"companies",
|
|
"company-1",
|
|
"claude-prompt-cache",
|
|
);
|
|
|
|
expect(capture1.addDir).toBeTruthy();
|
|
expect(capture1.addDir).toBe(capture2.addDir);
|
|
expect(capture1.instructionsFilePath).toBeTruthy();
|
|
expect(capture2.instructionsFilePath ?? null).toBeNull();
|
|
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).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();
|
|
expect(capture2.argv).toContain("11111111-1111-4111-8111-111111111111");
|
|
if (change === "removed") expect(capture2.mcpConfigContents).toBeNull();
|
|
else {
|
|
expect(capture2.mcpConfigContents).toContain("next-run-jwt-token");
|
|
expect(capture2.mcpConfigContents).not.toContain('"run-jwt-token"');
|
|
if (change === "added") expect(capture2.mcpConfigContents).toContain("fresh-github-token");
|
|
}
|
|
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
|
|
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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");
|
|
const commandPath = path.join(root, "claude");
|
|
const capturePath1 = path.join(root, "capture-before.json");
|
|
const capturePath2 = path.join(root, "capture-after.json");
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
const paperclipHome = path.join(root, "paperclip-home");
|
|
const logs: string[] = [];
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
|
|
await writeFakeClaudeCommand(commandPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
|
process.env.HOME = root;
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
|
|
try {
|
|
const first = await execute({
|
|
runId: "run-before",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
|
|
|
|
const second = await execute({
|
|
runId: "run-after",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: first.sessionParams ?? null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async (_stream, chunk) => {
|
|
logs.push(chunk);
|
|
},
|
|
});
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
expect(second.errorMessage).toBeNull();
|
|
|
|
const before = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
|
|
const after = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
|
|
|
|
expect(before.instructionsFilePath).not.toBe(after.instructionsFilePath);
|
|
expect(after.argv).not.toContain("--resume");
|
|
expect(after.prompt).toContain("Follow the paperclip heartbeat.");
|
|
expect(logs.join("")).toContain("will not be resumed with");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 15_000);
|
|
|
|
it("classifies Claude 'out of extra usage' failures as provider quota errors", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-transient-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "claude");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await writeFailingClaudeCommand(commandPath, {
|
|
resultEvent: {
|
|
type: "result",
|
|
subtype: "error",
|
|
session_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
is_error: true,
|
|
result: "You're out of extra usage · resets 4pm (America/Chicago)",
|
|
errors: [{ type: "rate_limit_error", message: "You're out of extra usage" }],
|
|
},
|
|
});
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date(2026, 3, 22, 10, 15, 0));
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-claude-transient",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).toBe("provider_quota");
|
|
expect(result.errorFamily).toBe("provider_quota");
|
|
const expectedRetryNotBefore = "2026-04-22T21:00:00.000Z";
|
|
expect(result.retryNotBefore).toBe(expectedRetryNotBefore);
|
|
expect(result.resultJson?.retryNotBefore).toBe(expectedRetryNotBefore);
|
|
expect(result.resultJson?.providerQuotaRetryNotBefore).toBe(expectedRetryNotBefore);
|
|
expect(result.errorMessage ?? "").toContain("extra usage");
|
|
expect(new Date(String(result.resultJson?.transientRetryNotBefore)).getTime()).toBe(
|
|
new Date("2026-04-22T21:00:00.000Z").getTime(),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("treats subtype=success results as successful even when the process exits nonzero", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-success-subtype-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "claude");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await writeFailingClaudeCommand(commandPath, {
|
|
exitCode: 1,
|
|
resultEvent: {
|
|
type: "result",
|
|
subtype: "success",
|
|
session_id: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
|
is_error: false,
|
|
result: "Implemented the requested change.",
|
|
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
|
},
|
|
});
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-claude-success-subtype",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorMessage).toBeNull();
|
|
expect(result.errorCode).toBeNull();
|
|
expect(result.summary).toBe("Implemented the requested change.");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("classifies rate-limit / overloaded failures without reset metadata as transient", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-rate-limit-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "claude");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await writeFailingClaudeCommand(commandPath, {
|
|
resultEvent: {
|
|
type: "result",
|
|
subtype: "error",
|
|
session_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
is_error: true,
|
|
result: "Overloaded",
|
|
errors: [{ type: "overloaded_error", message: "Overloaded_error: API is overloaded." }],
|
|
},
|
|
});
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-claude-overloaded",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).toBe("claude_transient_upstream");
|
|
expect(result.errorFamily).toBe("transient_upstream");
|
|
expect(result.retryNotBefore ?? null).toBeNull();
|
|
expect(result.resultJson?.retryNotBefore ?? null).toBeNull();
|
|
expect(result.resultJson?.transientRetryNotBefore ?? null).toBeNull();
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not reclassify deterministic Claude failures (auth, max turns) as transient", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-max-turns-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "claude");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await writeFailingClaudeCommand(commandPath, {
|
|
resultEvent: {
|
|
type: "result",
|
|
subtype: "error_max_turns",
|
|
session_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
is_error: true,
|
|
result: "Maximum turns reached.",
|
|
},
|
|
});
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-claude-max-turns",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: { engine: "cli" },
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).not.toBe("claude_transient_upstream");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("auto-rotates session on previous_message_id 400 (synthetic-msg poisoning) and succeeds on retry", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-msgid-"));
|
|
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writePoisonedMessageIdClaudeCommand,
|
|
});
|
|
const logs: string[] = [];
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-poisoned-msgid",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
|
PAPERCLIP_TEST_STATE_PATH: statePath,
|
|
},
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async (_stream, chunk) => { logs.push(chunk); },
|
|
});
|
|
|
|
const captured: Array<{ argv: string[] }> = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
|
// First attempt resumes, second attempt starts fresh
|
|
expect(captured).toHaveLength(2);
|
|
expect(captured[0]?.argv).toContain("--resume");
|
|
expect(captured[1]?.argv).not.toContain("--resume");
|
|
// Result comes from the fresh retry
|
|
expect(result.sessionId).toBe("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
|
|
expect(result.errorCode).toBeNull();
|
|
// Adapter logged the fallback reason
|
|
expect(logs.some((l) => l.includes("poisoned message-id"))).toBe(true);
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Regression for RED-978: the adapter must not persist a sessionId from a
|
|
* run that ended with a poisoned previous_message_id error. Otherwise the
|
|
* next continuation auto-resumes a known-bad transcript and Anthropic
|
|
* /v1/messages returns 400 again, permanently stranding the issue.
|
|
*/
|
|
it("drops sessionId and forces clearSession when a fresh run reports a poisoned previous_message_id", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-fresh-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writeAlwaysPoisonedMessageIdClaudeCommand,
|
|
});
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-poisoned-fresh",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
// The fake CLI emits a session_id in its poisoned result; the adapter
|
|
// must not propagate it. The server uses clearSession=true to wipe
|
|
// any previously-persisted session state for this issue/task.
|
|
expect(result.sessionId).toBeNull();
|
|
expect(result.sessionParams).toBeNull();
|
|
expect(result.sessionDisplayId).toBeNull();
|
|
expect(result.clearSession).toBe(true);
|
|
expect(result.errorCode).toBe("claude_poisoned_previous_message_id");
|
|
expect(result.errorMessage ?? "").toContain("previous_message_id");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Regression for RED-978: if the auto-retry after a poisoned resume *also*
|
|
* fails with a poisoned previous_message_id, the adapter must still emit
|
|
* clearSession=true so the next heartbeat starts from a clean transcript.
|
|
* Before this fix, the retry result's session_id ("fffff111-0000-4000-8000-000000000003")
|
|
* was persisted and every subsequent continuation hit the same 400 again.
|
|
*/
|
|
it("forces clearSession when the recovery retry also reports a poisoned previous_message_id", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-retry-"));
|
|
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
|
commandWriter: writeAlwaysPoisonedMessageIdClaudeCommand,
|
|
});
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-poisoned-retry",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
|
|
runtime: {
|
|
sessionId: "aaaaaaaa-0000-4000-8000-000000000004",
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
|
promptTemplate: "Do work.",
|
|
},
|
|
context: {},
|
|
authToken: "tok",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
const captured: Array<{ argv: string[] }> = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
|
// Resume attempt + fresh recovery attempt, both poisoned.
|
|
expect(captured).toHaveLength(2);
|
|
expect(captured[0]?.argv).toContain("--resume");
|
|
expect(captured[1]?.argv).not.toContain("--resume");
|
|
// Crucially: do NOT persist the retry's reported sessionId.
|
|
expect(result.sessionId).toBeNull();
|
|
expect(result.sessionParams).toBeNull();
|
|
expect(result.clearSession).toBe(true);
|
|
expect(result.errorCode).toBe("claude_poisoned_previous_message_id");
|
|
} finally {
|
|
restore();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|