import { describe, it, expect, beforeEach, afterEach } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { startWorkflow, reviewWorkflow, decideWorkflow, retryExecuteWorkflow } from "./workflow-engine.js"; import { readState, runDir } from "./workflow-state.js"; import type { HostDecisionV1 } from "./workflow-types.js"; // Helper to create a fake CLI script function writeFakeCli(dir: string, name: string, scriptBody: string): string { const p = path.join(dir, name); fs.writeFileSync(p, `#!/bin/bash\n${scriptBody}`, { mode: 0o755 }); return p; } describe("Workflow Integration (start -> review -> fix -> review -> accept)", () => { let tmpDir: string; let baseTmp: string; let homeDir: string; let workflowDir: string; let originalHome: string | undefined; let originalExit: typeof process.exit; let originalWorkflowDir: string | undefined; beforeEach(() => { originalExit = process.exit; (process as any).exit = (code?: number) => { if (code === 0) throw new Error("MockExit0"); originalExit(code); }; delete process.env.GIT_DIR; delete process.env.GIT_WORK_TREE; baseTmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-integration-"))); tmpDir = path.join(baseTmp, "John's 项目 空格+中文"); fs.mkdirSync(tmpDir); homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-home-"))); workflowDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-workflows-"))); originalHome = process.env.HOME; originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR; process.env.HOME = homeDir; process.env.AGENT_WORKFLOW_DIR = workflowDir; // Init real git repo for baselineHead execFileSync("git", ["init"], { cwd: tmpDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: tmpDir }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tmpDir }); fs.writeFileSync(path.join(tmpDir, "index.ts"), "const x = 1;\n"); execFileSync("git", ["add", "index.ts"], { cwd: tmpDir }); execFileSync("git", ["commit", "-m", "init"], { cwd: tmpDir }); }); afterEach(() => { fs.rmSync(baseTmp, { recursive: true, force: true }); fs.rmSync(homeDir, { recursive: true, force: true }); fs.rmSync(workflowDir, { recursive: true, force: true }); if (originalHome !== undefined) { process.env.HOME = originalHome; } else { delete process.env.HOME; } if (originalWorkflowDir !== undefined) { process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir; } else { delete process.env.AGENT_WORKFLOW_DIR; } process.exit = originalExit; }); it("completes a full loop with reasonix", async () => { // 1. Fake Executor CLI (Reasonix) const executorScript = ` if [[ "$*" == *"--version"* ]]; then echo "v1.0.0" exit 0 fi if [[ "$*" == *"--resume"* ]]; then if [[ "$*" == *"# Scope Remediation"* ]]; then if [[ ! -f "$HOME/scope-retry-ready" ]]; then : > "$HOME/scope-retry-ready" echo "session busy" >&2 exit 1 fi # Cycle 2: remove only the listed SQLite sidecars. rm -f "$3/tmp_oc.db-shm" "$3/tmp_oc.db-wal" echo "Scope cleaned!" else # Cycle 3: fix the Codex finding. echo "const x = 3;" > "$3/index.ts" echo "Fixed!" fi exit 0 else # Cycle 1: implement with a bug and accidentally leave SQLite sidecars. echo "const x = 2; // bug" > "$3/index.ts" : > "$3/tmp_oc.db-shm" : > "$3/tmp_oc.db-wal" echo "Done!" # Create fake session file for reasonix mkdir -p "$HOME/.reasonix/projects/mock-project/sessions" echo '{"role": "system", "content": "Current workspace: \\"'"$3"'\\""}' > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl" echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl.meta" # Create a newer subagent session to verify it gets correctly ignored mkdir -p "$HOME/.reasonix/projects/mock-project/sessions/subagents" sleep 1 echo '{"role": "system", "content": "Current workspace: '"$3"'"}' > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl" echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl.meta" fi `; const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript); fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({ version: "1", maxCycles: 3, executors: { reasonix: { binary: executorBin } } })); const plan = { version: "1", title: "Add a file", planMarkdown: "Add an index.ts file.", scope: ["index.ts"], acceptanceCriteria: ["x is 3"], verificationCommands: [["grep", "const x = 3;", "index.ts"]], } as const; const planPath = path.join(tmpDir, "plan.json"); fs.writeFileSync(planPath, JSON.stringify(plan)); execFileSync("git", ["add", "."], { cwd: tmpDir }); execFileSync("git", ["commit", "-m", "config"], { cwd: tmpDir }); // --- STEP 1: START --- const { runId } = await startWorkflow({ cwd: tmpDir, executor: "reasonix", planInput: planPath, }); const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: tmpDir, encoding: "utf8" }).trim(); let state = readState(runDir(repoRoot, runId))!; expect(state.status).toBe("awaiting_review"); expect(state.currentCycle).toBe(1); // --- STEP 2: SCOPE GATE (Cycle 1, Codex review bundle is not ready yet) --- await reviewWorkflow({ runId, cwd: tmpDir }); let state2 = readState(runDir(repoRoot, runId))!; expect(state2.status).toBe("awaiting_scope_resolution"); expect(state2.cycles[0].scopeViolations).toEqual([ { id: "scope-1", path: "tmp_oc.db-shm", kind: "out_of_scope" }, { id: "scope-2", path: "tmp_oc.db-wal", kind: "out_of_scope" }, ]); expect(state2.cycles[0].hostReviewFile).toBeUndefined(); const incompleteDecision: HostDecisionV1 = { version: "1", outcome: "fix", findingDecisions: [{ findingId: "scope-1", disposition: "accept" }], decidedAt: new Date().toISOString(), }; const incompleteDecisionPath = path.join(process.env.HOME!, "decision-incomplete.json"); fs.writeFileSync(incompleteDecisionPath, JSON.stringify(incompleteDecision)); await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath })) .rejects.toThrow(/accept every current violation exactly once/); expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_scope_resolution"); // --- STEP 3: DECIDE (Scope fix using the same Reasonix session) --- const scopeDecision: HostDecisionV1 = { version: "1", outcome: "fix", findingDecisions: [ { findingId: "scope-1", disposition: "accept" }, { findingId: "scope-2", disposition: "accept" }, ], decidedAt: new Date().toISOString() }; const scopeDecisionPath = path.join(process.env.HOME!, "decision-scope.json"); fs.writeFileSync(scopeDecisionPath, JSON.stringify(scopeDecision)); await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath })) .rejects.toThrow(/Executor failed/); expect(readState(runDir(repoRoot, runId))?.status).toBe("blocked"); await retryExecuteWorkflow({ runId }); let state3 = readState(runDir(repoRoot, runId))!; expect(state3.status).toBe("awaiting_review"); expect(state3.currentCycle).toBe(2); expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-shm"))).toBe(false); expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-wal"))).toBe(false); // --- STEP 4: PREPARE CODEX REVIEW (Cycle 2) --- await reviewWorkflow({ runId, cwd: tmpDir }); let state4 = readState(runDir(repoRoot, runId))!; expect(state4.status).toBe("awaiting_host"); expect(state4.cycles[1].hostReviewFile).toBe("cycle-2-host-review.json"); const reviewBundle = JSON.parse(fs.readFileSync( path.join(runDir(repoRoot, runId), state4.cycles[1].hostReviewFile!), "utf8", )); expect(reviewBundle.repoRoot).toBe(repoRoot); expect(reviewBundle.diffFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2.diff")); expect(reviewBundle.executorLogFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2-exec-retry-1.log")); expect(state4.cycles[1].executorAttemptLogs).toEqual([ "cycle-2-exec.log", "cycle-2-exec-retry-1.log", ]); // --- STEP 5: CODEX DECIDES FIX --- const incompleteFix: HostDecisionV1 = { version: "1", outcome: "fix", findingDecisions: [{ findingId: "codex-1", disposition: "accept" }], decidedAt: new Date().toISOString(), }; const incompleteFixPath = path.join(process.env.HOME!, "decision-fix-incomplete.json"); fs.writeFileSync(incompleteFixPath, JSON.stringify(incompleteFix)); await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath })) .rejects.toThrow(/require a non-empty summary/); expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_host"); const fixDecision: HostDecisionV1 = { version: "1", outcome: "fix", findingDecisions: [{ findingId: "codex-1", disposition: "accept", summary: "index.ts sets x to 2 instead of the required value 3", path: "index.ts", }], decidedAt: new Date().toISOString() }; const fixDecisionPath = path.join(process.env.HOME!, "decision-fix.json"); fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision)); await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath }); let state5 = readState(runDir(repoRoot, runId))!; expect(state5.status).toBe("awaiting_review"); expect(state5.currentCycle).toBe(3); // --- STEP 6: PREPARE CODEX RE-REVIEW (Cycle 3) --- await reviewWorkflow({ runId, cwd: tmpDir }); let state6 = readState(runDir(repoRoot, runId))!; expect(state6.status).toBe("awaiting_host"); expect(state6.cycles[2].hostReviewFile).toBe("cycle-3-host-review.json"); // --- STEP 7: DECIDE (Accept) --- const acceptDecision: HostDecisionV1 = { version: "1", outcome: "accept", findingDecisions: [], verificationEvidence: ["verified output"], decidedAt: new Date().toISOString() }; const acceptDecisionPath = path.join(process.env.HOME!, "decision2.json"); fs.writeFileSync(acceptDecisionPath, JSON.stringify(acceptDecision)); try { await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath }); } catch (err: any) { if (err.message !== "MockExit0") throw err; } let state7 = readState(runDir(repoRoot, runId))!; expect(state7.status).toBe("completed"); }); });