feat: add Codex executor with session resume

This commit is contained in:
2026-07-22 22:15:15 +08:00
parent d38151ad78
commit 214b7c9a30
19 changed files with 1855 additions and 33 deletions
+181
View File
@@ -169,3 +169,184 @@ echo 'started'
abortWorkflow({ runId: fresh.runId, reason: "test cleanup" });
});
});
describe("Codex task-level executor session reuse", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let homeDir: string;
let previousHome: string | undefined;
let previousWorkflowDir: string | undefined;
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee162f";
const sessionUuid = "b7a8c901-2345-4def-9012-3456789abcde";
let codexBin: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-reuse-"));
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "state");
homeDir = path.join(tmpDir, "home");
codexBin = path.join(tmpDir, "fake-codex.sh");
fs.mkdirSync(repoRoot);
fs.mkdirSync(homeDir);
previousHome = process.env.HOME;
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.HOME = homeDir;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
// Create a valid codex session file in ~/.codex/sessions/
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: repoRoot } }) + "\n" +
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Codex Plan" } }) + "\n",
);
// Create a fake Codex CLI that records args and emits thread.started on start/resume
const uuid = sessionUuid; // captured for bash var expansion
fs.writeFileSync(codexBin, `#!/bin/bash
# Fake Codex CLI: record args to NUL-delimited file, emit thread.started
printf '%s\\0' "$@" >> '${path.join(tmpDir, "codex-args.bin")}'
if [[ "$1" == "--version" ]]; then
echo "fake-codex 0.1.0"
exit 0
fi
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
# Session ID is immediately before the final "-" prompt marker
resumedId="\${@: -2:1}"
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
echo '{"type":"turn.completed"}'
echo '{"type":"result","result":"Resume complete"}'
exit 0
elif [[ "$1" == "exec" ]]; then
echo "{\\"type\\":\\"thread.started\\",\\"thread\\":{\\"id\\":\\"${uuid}\\"}}"
echo '{"type":"item.started","item":{"name":"Bash"}}'
echo '{"type":"item.completed","item":{"name":"Bash"}}'
echo '{"type":"turn.completed"}'
exit 0
else
exit 1
fi
`, { mode: 0o755 });
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
version: "1",
executors: { codex: { binary: codexBin } },
}));
fs.writeFileSync(path.join(repoRoot, "tracked.txt"), "unchanged\n");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot, stdio: "ignore" });
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writePlan(name: string): string {
const planPath = path.join(tmpDir, `${name}.json`);
fs.writeFileSync(planPath, JSON.stringify({
version: "1",
title: name,
planMarkdown: `Implement ${name}.`,
scope: ["tracked.txt"],
acceptanceCriteria: [`${name} is complete`],
verificationCommands: [["git", "status", "--short"]],
}));
return planPath;
}
it("resumes the same Codex session when a later plan belongs to the current task", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex First Plan"),
codexThreadId: threadId,
});
const firstState = readState(runDir(repoRoot, first.runId))!;
const firstTaskId = firstState.taskId;
expect(firstState.taskSessionReused).toBe(false);
expect(firstState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
expect(readTaskBinding(repoRoot, threadId)?.executors.codex?.handle).toEqual(firstState.sessionHandle);
abortWorkflow({ runId: first.runId, reason: "test boundary" });
const second = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Second Plan"),
codexThreadId: threadId,
});
const secondState = readState(runDir(repoRoot, second.runId))!;
expect(secondState.taskId).toBe(firstTaskId);
expect(secondState.taskSessionReused).toBe(true);
// The resumed session must use the EXACT same ID
expect(secondState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
const args = fs.readFileSync(path.join(tmpDir, "codex-args.bin"), "utf8").split("\0").filter(Boolean);
expect(args).toContain("resume");
expect(args).toContain(sessionUuid);
const events = fs.readFileSync(path.join(runDir(repoRoot, second.runId), "events.jsonl"), "utf8");
expect(events).toContain('"kind":"executor_session_reused"');
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
});
it("blocks reuse when Codex session file is missing from ~/.codex/sessions", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Remove the session file so validation fails
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.rmSync(path.join(sessionsDir, `${sessionUuid}.jsonl`), { force: true });
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Follow-up Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/Codex session file not found/);
});
it("blocks reuse when Codex session belongs to a different repository", async () => {
const first = await startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Recoverable Task 2"),
codexThreadId: threadId,
});
abortWorkflow({ runId: first.runId, reason: "test boundary" });
// Rewrite the session file with a different cwd
const sessionsDir = path.join(homeDir, ".codex", "sessions");
fs.writeFileSync(
path.join(sessionsDir, `${sessionUuid}.jsonl`),
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: "/different/repo" } }) + "\n",
);
await expect(startWorkflow({
cwd: repoRoot,
executor: "codex",
reviewer: "claude",
planInput: writePlan("Codex Wrong Repo Plan"),
codexThreadId: threadId,
})).rejects.toThrow(/does not belong to repository/);
});
});