fix: preserve claude instructions on resume fallback

This commit is contained in:
dotta 2026-04-08 06:52:35 -05:00
parent fa3cbc7fdb
commit 0ff262ca0f
2 changed files with 136 additions and 23 deletions

View file

@ -377,13 +377,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
// When instructionsFilePath is configured, create a combined temp file that
// includes both the file content and the path directive, so we only need
// --append-system-prompt-file (Claude CLI forbids using both flags together).
// Skipped on resumed sessions — the instructions are already baked into the
// session cache and re-injecting them wastes tokens.
let effectiveInstructionsFilePath: string | undefined = instructionsFilePath;
if (instructionsFilePath && !canResumeSession) {
let effectiveInstructionsFilePath: string | undefined;
let preparedInstructionsFile = false;
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
if (resumeSessionId || !instructionsFilePath) return undefined;
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
preparedInstructionsFile = true;
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;
@ -398,16 +399,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
effectiveInstructionsFilePath = undefined;
}
} else if (canResumeSession) {
effectiveInstructionsFilePath = undefined;
}
const commandNotes =
instructionsFilePath && !sessionId
? [
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
]
: [];
return effectiveInstructionsFilePath;
};
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
@ -440,7 +434,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const buildClaudeArgs = (resumeSessionId: string | null) => {
const buildClaudeArgs = (
resumeSessionId: string | null,
attemptInstructionsFilePath: string | undefined,
) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
@ -456,8 +453,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// On resumed sessions the instructions are already in the session cache;
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
// per heartbeat and the Claude CLI may reject the combination outright.
if (effectiveInstructionsFilePath && !resumeSessionId) {
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
if (attemptInstructionsFilePath && !resumeSessionId) {
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
}
args.push("--add-dir", skillsDir);
if (extraArgs.length > 0) args.push(...extraArgs);
@ -481,7 +478,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildClaudeArgs(resumeSessionId);
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
const commandNotes =
attemptInstructionsFilePath && !resumeSessionId
? [
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
]
: [];
if (onMeta) {
await onMeta({
adapterType: "claude_local",

View file

@ -9,10 +9,14 @@ async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_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) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
@ -25,20 +29,65 @@ console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", res
await fs.chmod(commandPath, 0o755);
}
async function setupExecuteEnv(root: string) {
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 writeFakeClaudeCommand(commandPath);
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,
workspace, commandPath, capturePath, statePath,
restore: () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
@ -224,6 +273,66 @@ describe("claude execute", () => {
}
});
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)}/.`,
);
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");