mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
fix: preserve claude instructions on resume fallback
This commit is contained in:
parent
fa3cbc7fdb
commit
0ff262ca0f
2 changed files with 136 additions and 23 deletions
|
|
@ -377,13 +377,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When instructionsFilePath is configured, create a combined temp file that
|
let effectiveInstructionsFilePath: string | undefined;
|
||||||
// includes both the file content and the path directive, so we only need
|
let preparedInstructionsFile = false;
|
||||||
// --append-system-prompt-file (Claude CLI forbids using both flags together).
|
|
||||||
// Skipped on resumed sessions — the instructions are already baked into the
|
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
|
||||||
// session cache and re-injecting them wastes tokens.
|
if (resumeSessionId || !instructionsFilePath) return undefined;
|
||||||
let effectiveInstructionsFilePath: string | undefined = instructionsFilePath;
|
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
|
||||||
if (instructionsFilePath && !canResumeSession) {
|
|
||||||
|
preparedInstructionsFile = true;
|
||||||
try {
|
try {
|
||||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
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}.`;
|
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;
|
effectiveInstructionsFilePath = undefined;
|
||||||
}
|
}
|
||||||
} else if (canResumeSession) {
|
|
||||||
effectiveInstructionsFilePath = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandNotes =
|
return effectiveInstructionsFilePath;
|
||||||
instructionsFilePath && !sessionId
|
};
|
||||||
? [
|
|
||||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||||
const templateData = {
|
const templateData = {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
|
@ -440,7 +434,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
const buildClaudeArgs = (
|
||||||
|
resumeSessionId: string | null,
|
||||||
|
attemptInstructionsFilePath: string | undefined,
|
||||||
|
) => {
|
||||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
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;
|
// On resumed sessions the instructions are already in the session cache;
|
||||||
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
|
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
|
||||||
// per heartbeat and the Claude CLI may reject the combination outright.
|
// per heartbeat and the Claude CLI may reject the combination outright.
|
||||||
if (effectiveInstructionsFilePath && !resumeSessionId) {
|
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||||
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
||||||
}
|
}
|
||||||
args.push("--add-dir", skillsDir);
|
args.push("--add-dir", skillsDir);
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
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 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) {
|
if (onMeta) {
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,14 @@ async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
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 = {
|
const payload = {
|
||||||
argv: process.argv.slice(2),
|
argv: process.argv.slice(2),
|
||||||
prompt: fs.readFileSync(0, "utf8"),
|
prompt: fs.readFileSync(0, "utf8"),
|
||||||
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
|
||||||
|
appendedSystemPromptFilePath,
|
||||||
|
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
|
||||||
};
|
};
|
||||||
if (capturePath) {
|
if (capturePath) {
|
||||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
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);
|
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 workspace = path.join(root, "workspace");
|
||||||
const binDir = path.join(root, "bin");
|
const binDir = path.join(root, "bin");
|
||||||
const commandPath = path.join(binDir, "claude");
|
const commandPath = path.join(binDir, "claude");
|
||||||
const capturePath = path.join(root, "capture.json");
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
const statePath = path.join(root, "state.txt");
|
||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.mkdir(binDir, { recursive: true });
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
await writeFakeClaudeCommand(commandPath);
|
await (options?.commandWriter ?? writeFakeClaudeCommand)(commandPath);
|
||||||
const previousHome = process.env.HOME;
|
const previousHome = process.env.HOME;
|
||||||
const previousPath = process.env.PATH;
|
const previousPath = process.env.PATH;
|
||||||
process.env.HOME = root;
|
process.env.HOME = root;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||||
return {
|
return {
|
||||||
workspace, commandPath, capturePath,
|
workspace, commandPath, capturePath, statePath,
|
||||||
restore: () => {
|
restore: () => {
|
||||||
if (previousHome === undefined) delete process.env.HOME;
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
else process.env.HOME = previousHome;
|
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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-"));
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue