mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Adapter execution and retry classification decide whether agent work pauses, retries, or recovers automatically > - Transient provider failures need to be classified precisely so Paperclip does not convert retryable upstream conditions into false hard failures > - At the same time, operators need an up-to-date model list for Codex-backed agents and prompts should nudge agents toward targeted verification instead of repo-wide sweeps > - This pull request tightens transient recovery classification for Claude and Codex, updates the agent prompt guidance, and adds Codex model refresh support end-to-end > - The benefit is better automatic retry behavior plus fresher operator-facing model configuration ## What Changed - added Codex usage-limit retry-window parsing and Claude extra-usage transient classification - normalized the heartbeat transient-recovery contract across adapter executions and heartbeat scheduling - documented that deferred comment wakes only reopen completed issues for human/comment-reopen interactions, while system follow-ups leave closed work closed - updated adapter-utils prompt guidance to prefer targeted verification - added Codex model refresh support in the server route, registry, shared types, and agent config form - added adapter/server tests covering the new parsing, retry scheduling, and model-refresh behavior ## Verification - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-claude-local packages/adapters/claude-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/adapter-codex-local packages/adapters/codex-local/src/server/parse.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/adapter-model-refresh-routes.test.ts server/src/__tests__/adapter-models.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts` ## Risks - Moderate behavior risk: retry classification affects whether runs auto-recover or block, so mistakes here could either suppress needed retries or over-retry real failures - Low workflow risk: deferred comment wake reopening is intentionally scoped to human/comment-reopen interactions so system follow-ups do not revive completed issues unexpectedly > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
839 lines
32 KiB
TypeScript
839 lines
32 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execute } 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 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 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() : [],
|
|
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
|
};
|
|
if (capturePath) {
|
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", 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;
|
|
appendedSystemPromptFilePath?: string | null;
|
|
appendedSystemPromptFileContents?: string | null;
|
|
};
|
|
|
|
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: "claude-session-1",
|
|
result: "No conversation found with session id claude-session-1",
|
|
errors: ["No conversation found with session id claude-session-1"],
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-2", model: "claude-sonnet" }));
|
|
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-2", message: { content: [{ type: "text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", session_id: "claude-session-2", 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;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("claude execute", () => {
|
|
/**
|
|
* 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: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
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("--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: {} },
|
|
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
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: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
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: {} },
|
|
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
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[] }> = [];
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-resume-fallback",
|
|
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
|
runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
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) => {
|
|
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(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).toContain(
|
|
`The above agent instructions 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("claude-session-2");
|
|
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: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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(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("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
|
|
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;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
|
|
try {
|
|
const first = await execute({
|
|
runId: "run-1",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(first.errorMessage).toBeNull();
|
|
expect(first.sessionParams).toMatchObject({
|
|
sessionId: "claude-session-1",
|
|
cwd: workspace,
|
|
});
|
|
expect(typeof first.sessionParams?.promptBundleKey).toBe("string");
|
|
|
|
const second = await execute({
|
|
runId: "run-2",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: first.sessionParams ?? null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
instructionsFilePath: instructionsPath,
|
|
env: {
|
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
|
},
|
|
promptTemplate: "Follow the paperclip heartbeat.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-2",
|
|
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,
|
|
},
|
|
},
|
|
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).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
|
|
expect(capture1.skillEntries).toContain("paperclip");
|
|
expect(capture2.argv).toContain("--resume");
|
|
expect(capture2.argv).toContain("claude-session-1");
|
|
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("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;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
|
|
try {
|
|
const first = await execute({
|
|
runId: "run-before",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Claude Coder",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: first.sessionParams ?? null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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 transient upstream 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: "claude-session-extra",
|
|
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: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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).toBe("2026-04-22T21:00:00.000Z");
|
|
expect(result.resultJson?.retryNotBefore).toBe("2026-04-22T21:00:00.000Z");
|
|
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("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: "claude-session-overloaded",
|
|
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: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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: "claude-session-max-turns",
|
|
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: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
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 });
|
|
}
|
|
});
|
|
});
|