import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { abortWorkflow, startWorkflow } from "./workflow-engine.js"; import { readState, runDir } from "./workflow-state.js"; import { readTaskBinding } from "./workflow-task-binding.js"; describe("task-level executor session reuse", () => { let tmpDir: string; let repoRoot: string; let stateRoot: string; let homeDir: string; let argsFile: string; let previousHome: string | undefined; let previousWorkflowDir: string | undefined; const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee161f"; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-task-reuse-")); repoRoot = path.join(tmpDir, "repo"); stateRoot = path.join(tmpDir, "state"); homeDir = path.join(tmpDir, "home"); argsFile = path.join(tmpDir, "reasonix-args.bin"); 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 }); const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-"); const sessionsDir = path.join(homeDir, ".reasonix", "projects", projectKey, "sessions"); const sessionFile = path.join(sessionsDir, "task-session.jsonl"); const fakeBin = path.join(repoRoot, "fake-reasonix.sh"); fs.writeFileSync(fakeBin, `#!/bin/bash printf '%s\\0' "$@" >> '${argsFile}' if [[ "$1" == "--version" ]]; then exit 0; fi if [[ "$*" == *"--resume"* ]]; then echo 'resumed'; exit 0; fi mkdir -p '${sessionsDir}' printf '%s\\n' '{"role":"system","content":"Current workspace: \\"${repoRoot}\\""}' > '${sessionFile}' touch '${sessionFile}.meta' echo 'started' `, { mode: 0o755 }); fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({ version: "1", executors: { reasonix: { binary: fakeBin } }, })); 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 executor when a later plan belongs to the current Codex task", async () => { const first = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("First plan"), codexThreadId: threadId, }); const firstState = readState(runDir(repoRoot, first.runId))!; const firstTaskId = firstState.taskId; expect(firstState.taskSessionReused).toBe(false); expect(readTaskBinding(repoRoot, threadId)?.executors.reasonix?.handle).toEqual(firstState.sessionHandle); abortWorkflow({ runId: first.runId, reason: "test boundary" }); const second = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("Second plan"), codexThreadId: threadId, }); const secondState = readState(runDir(repoRoot, second.runId))!; expect(secondState.taskId).toBe(firstTaskId); expect(secondState.taskSessionReused).toBe(true); expect(secondState.sessionHandle).toEqual(firstState.sessionHandle); const args = fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean); expect(args).toContain("--resume"); expect(args).toContain((firstState.sessionHandle as { sessionFilePath: string }).sessionFilePath); expect(args.some((arg) => arg.includes("# Continued Task Plan: Second plan"))).toBe(true); 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("starts a fresh task and session when newTask is explicit", async () => { const first = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("First task"), codexThreadId: threadId, }); const firstTaskId = readState(runDir(repoRoot, first.runId))?.taskId; abortWorkflow({ runId: first.runId, reason: "test boundary" }); fs.rmSync(argsFile, { force: true }); const second = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("New task"), codexThreadId: threadId, newTask: true, }); const secondState = readState(runDir(repoRoot, second.runId))!; expect(secondState.taskId).not.toBe(firstTaskId); expect(secondState.taskSessionReused).toBe(false); expect(fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean)).not.toContain("--resume"); abortWorkflow({ runId: second.runId, reason: "test cleanup" }); }); it("blocks a missing bound session instead of silently replacing it", async () => { const first = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("Recoverable task"), codexThreadId: threadId, }); const handle = readState(runDir(repoRoot, first.runId))?.sessionHandle; abortWorkflow({ runId: first.runId, reason: "test boundary" }); expect(handle?.kind).toBe("reasonix"); if (handle?.kind === "reasonix") { fs.rmSync(handle.sessionFilePath, { force: true }); fs.rmSync(`${handle.sessionFilePath}.meta`, { force: true }); } await expect(startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("Follow-up plan"), codexThreadId: threadId, })).rejects.toThrow(/Cannot reuse the current task's reasonix session/); const fresh = await startWorkflow({ cwd: repoRoot, executor: "reasonix", planInput: writePlan("Intentional new task"), codexThreadId: threadId, newTask: true, }); expect(readState(runDir(repoRoot, fresh.runId))?.taskSessionReused).toBe(false); 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/); }); });