feat: initialize standalone agent-workflow
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Unit tests for executor adapters (Sprint 2).
|
||||
*
|
||||
* Uses fake CLI binaries to test argument escaping, timeout/cancel behavior,
|
||||
* session handle detection, and degraded-resume logic.
|
||||
*
|
||||
* These tests mock out the actual executors and verify the adapter logic
|
||||
* without running real AI agents.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createExecutorAdapter } from "./workflow-executor.js";
|
||||
import type { WorkflowPlanV1 } from "./workflow-types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-executor-test-"));
|
||||
}
|
||||
|
||||
function cleanupDir(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeValidPlan(): WorkflowPlanV1 {
|
||||
return {
|
||||
version: "1",
|
||||
title: "Test plan",
|
||||
planMarkdown: "Do something.",
|
||||
scope: ["src/"],
|
||||
acceptanceCriteria: ["Tests pass"],
|
||||
verificationCommands: [["npm", "test"]],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake CLI builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a fake CLI script that:
|
||||
* 1. Logs its arguments to a file
|
||||
* 2. Exits with the given code
|
||||
* 3. Optionally writes JSON with a session_id/conversation_id to stdout
|
||||
*/
|
||||
function writeFakeCli(dir: string, opts: {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
argsFile?: string;
|
||||
}): string {
|
||||
const scriptPath = path.join(dir, "fake-cli.sh");
|
||||
const argsFile = opts.argsFile ?? path.join(dir, "args.txt");
|
||||
const exitCode = opts.exitCode ?? 0;
|
||||
const stdout = opts.stdout ?? "";
|
||||
|
||||
const script = `#!/bin/bash
|
||||
echo "$@" >> "${argsFile}"
|
||||
echo '${stdout.replace(/'/g, "'\\''")}'
|
||||
exit ${exitCode}
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasonix adapter tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ReasonixAdapter", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTmpDir(); });
|
||||
afterEach(() => { cleanupDir(tmpDir); });
|
||||
|
||||
it("probe returns false when binary not found", async () => {
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
// Override with a non-existent binary
|
||||
const result = await adapter.probe();
|
||||
// We expect false since 'reasonix' is not installed in CI
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("start builds args with --dir and no --file flag", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
// Should have been called with 'run --dir ... <promptFile>'
|
||||
expect(capturedArgs).toContain("run");
|
||||
expect(capturedArgs).toContain("--dir");
|
||||
expect(capturedArgs).not.toContain("--file");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("resume builds args with --resume", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
fs.writeFileSync(sessionFile, "{}\n");
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
const result = await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Fix null pointer at src/index.ts",
|
||||
handle: { kind: "reasonix", sessionFilePath: sessionFile },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--resume");
|
||||
expect(capturedArgs).toContain(sessionFile);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.sessionHandle?.kind).toBe("reasonix");
|
||||
});
|
||||
|
||||
it("reports failure when binary exits with non-zero", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 });
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.failed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude adapter tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ClaudeAdapter", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTmpDir(); });
|
||||
afterEach(() => { cleanupDir(tmpDir); });
|
||||
|
||||
it("start includes --session-id and -p flags", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, {
|
||||
argsFile,
|
||||
stdout: '{"session_id": "test-session-uuid"}',
|
||||
});
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("-p");
|
||||
expect(capturedArgs).toContain("--output-format");
|
||||
expect(capturedArgs).toContain("--session-id");
|
||||
expect(result.sessionHandle?.kind).toBe("claude");
|
||||
});
|
||||
|
||||
it("resume uses --resume with captured session ID", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
const result = await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Fix auth bypass",
|
||||
handle: { kind: "claude", sessionId: "captured-session-id" },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--resume");
|
||||
expect(capturedArgs).toContain("captured-session-id");
|
||||
expect(result.sessionHandle?.kind).toBe("claude");
|
||||
});
|
||||
|
||||
it("extracts session_id from JSON output", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, {
|
||||
stdout: '{"session_id": "extracted-id-123", "result": "done"}',
|
||||
});
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
if (result.sessionHandle?.kind === "claude") {
|
||||
expect(result.sessionHandle.sessionId).toBe("extracted-id-123");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agy adapter tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AgyAdapter", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTmpDir(); });
|
||||
afterEach(() => { cleanupDir(tmpDir); });
|
||||
|
||||
it("start uses --print and --mode accept-edits", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, {
|
||||
argsFile,
|
||||
stdout: '{"conversation_id": "agy-conv-id-456"}',
|
||||
});
|
||||
|
||||
const adapter = createExecutorAdapter("agy");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--print");
|
||||
expect(capturedArgs).toContain("--mode accept-edits");
|
||||
|
||||
if (result.sessionHandle?.kind === "agy") {
|
||||
expect(result.sessionHandle.conversationId).toBe("agy-conv-id-456");
|
||||
expect(result.sessionHandle.degraded).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to degraded mode when no conversation_id", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, { stdout: "Agy output without JSON" });
|
||||
|
||||
const adapter = createExecutorAdapter("agy");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
if (result.sessionHandle?.kind === "agy") {
|
||||
expect(result.sessionHandle.conversationId).toBeUndefined();
|
||||
expect(result.sessionHandle.degraded).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("resume with conversation_id uses --conversation flag", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
const adapter = createExecutorAdapter("agy");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Fix the bug",
|
||||
handle: { kind: "agy", conversationId: "known-conv-id", degraded: false },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--conversation");
|
||||
expect(capturedArgs).toContain("known-conv-id");
|
||||
});
|
||||
|
||||
it("degraded resume uses --continue flag", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
const adapter = createExecutorAdapter("agy");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Fix the bug",
|
||||
handle: { kind: "agy", degraded: true },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
|
||||
expect(capturedArgs).toContain("--continue");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safety constraints in prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Safety constraints in executor prompts", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTmpDir(); });
|
||||
afterEach(() => { cleanupDir(tmpDir); });
|
||||
|
||||
it("start prompt file contains no-commit constraint", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, {});
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
// Find the generated prompt file
|
||||
const promptFile = path.join(tmpDir, "cycle-1-prompt.txt");
|
||||
if (fs.existsSync(promptFile)) {
|
||||
const content = fs.readFileSync(promptFile, "utf8");
|
||||
expect(content).toContain("DO NOT run git commit");
|
||||
expect(content).toContain("mktemp");
|
||||
expect(content).toContain("-wal, -shm, and -journal");
|
||||
expect(content).toContain("git status --porcelain --untracked-files=all");
|
||||
expect(content).toContain("DO NOT");
|
||||
}
|
||||
});
|
||||
|
||||
it("resume prompt contains only accepted findings, not all findings", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, {});
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
fs.writeFileSync(sessionFile, "{}");
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "Finding C1: SQL injection in src/db.ts",
|
||||
handle: { kind: "reasonix", sessionFilePath: sessionFile },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const promptFile = path.join(tmpDir, "cycle-2-prompt.txt");
|
||||
if (fs.existsSync(promptFile)) {
|
||||
const content = fs.readFileSync(promptFile, "utf8");
|
||||
expect(content).toContain("SQL injection");
|
||||
expect(content).toContain("DO NOT run git commit");
|
||||
}
|
||||
});
|
||||
|
||||
it("scope remediation prompt authorizes only listed cleanup paths", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, {});
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
fs.writeFileSync(sessionFile, "{}");
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: [
|
||||
"# Scope Remediation",
|
||||
"",
|
||||
"- scope-1: tmp_oc.db-shm",
|
||||
"- scope-2: tmp_oc.db-wal",
|
||||
].join("\n"),
|
||||
handle: { kind: "reasonix", sessionFilePath: sessionFile },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 2,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, "cycle-2-prompt.txt"), "utf8");
|
||||
expect(content).toContain("# Scope Fix Request");
|
||||
expect(content).toContain("scope-1: tmp_oc.db-shm");
|
||||
expect(content).toContain("only the exact out-of-scope paths it lists");
|
||||
expect(content).toContain("Do not modify any other out-of-scope path");
|
||||
});
|
||||
|
||||
it("Claude adapter passes --safe-mode and --permission-mode bypassPermissions", async () => {
|
||||
const fakeBin = path.join(tmpDir, "claude");
|
||||
fs.writeFileSync(fakeBin, `#!/bin/bash
|
||||
echo '{"session_id":"test-session"}'
|
||||
echo "$@" > ${path.join(tmpDir, "args.txt")}
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const args = fs.readFileSync(path.join(tmpDir, "args.txt"), "utf8");
|
||||
expect(args).toContain("--safe-mode");
|
||||
expect(args).toContain("--permission-mode");
|
||||
expect(args).toContain("bypassPermissions");
|
||||
});
|
||||
|
||||
it("Claude adapter extracts errors from JSON result", async () => {
|
||||
const fakeBin = path.join(tmpDir, "claude");
|
||||
fs.writeFileSync(fakeBin, `#!/bin/bash
|
||||
echo '{"session_id":"test-session","errors":["Permission denied","File not found"]}'
|
||||
exit 1
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(result.failureReason).toContain("Permission denied");
|
||||
expect(result.failureReason).toContain("File not found");
|
||||
});
|
||||
|
||||
it("Claude adapter extracts is_error result messages", async () => {
|
||||
const fakeBin = path.join(tmpDir, "claude");
|
||||
fs.writeFileSync(fakeBin, `#!/bin/bash
|
||||
echo '{"session_id":"test-session","is_error":true,"result":"Session already active"}'
|
||||
exit 1
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const adapter = createExecutorAdapter("claude");
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(result.failureReason).toBe("Session already active");
|
||||
});
|
||||
|
||||
it("probe failure directs users to agent-workflow.json", async () => {
|
||||
const { probeExecutor } = await import("./workflow-executor.js");
|
||||
|
||||
// Use a valid executor kind but with a bad binary path
|
||||
await expect(probeExecutor("reasonix", { binary: "/nonexistent/path/reasonix" }))
|
||||
.rejects.toThrow(/agent-workflow\.json/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user