feat: initialize standalone agent-workflow

This commit is contained in:
liujing
2026-07-16 20:56:24 +08:00
commit 35e8fa3717
31 changed files with 6972 additions and 0 deletions
+471
View File
@@ -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/);
});
});