274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|