Files
agent-workflow/src/workflow-executor.test.ts
T

891 lines
32 KiB
TypeScript

/**
* 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, beforeAll, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createExecutorAdapter, findReasonixSessionForWorkflow } 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
printf '%s\\0' "$@" >> "${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("captures a session from the Reasonix project directory when JSONL workspace is a parent", async () => {
const previousHome = process.env.HOME;
const fakeHome = path.join(tmpDir, "home");
process.env.HOME = fakeHome;
try {
const projectKey = path.resolve(tmpDir).replaceAll(path.sep, "-");
const sessionsDir = path.join(fakeHome, ".reasonix", "projects", projectKey, "sessions");
const sessionFile = path.join(sessionsDir, "test-session.jsonl");
const fakeBin = path.join(tmpDir, "fake-reasonix.sh");
fs.writeFileSync(fakeBin, `#!/bin/bash
mkdir -p '${sessionsDir}'
printf '%s\\n' '{"role":"system","content":"Current workspace: \\"${path.dirname(tmpDir)}\\""}' > '${sessionFile}'
printf '%s\\n' '{"role":"user","content":"# Implementation Task: Test plan"}' >> '${sessionFile}'
touch '${sessionFile}.meta'
`, { mode: 0o755 });
const adapter = createExecutorAdapter("reasonix");
const startedAt = new Date(Date.now() - 1000).toISOString();
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "reasonix", sessionFilePath: sessionFile });
expect(findReasonixSessionForWorkflow(tmpDir, "Test plan", startedAt)).toBe(sessionFile);
expect(findReasonixSessionForWorkflow(tmpDir, "Different plan", startedAt)).toBeUndefined();
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
}
});
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("start passes --model when configured", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, { argsFile });
const adapter = createExecutorAdapter("reasonix");
await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin, model: "custom-reasonix-model" },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--model");
expect(capturedArgs).toContain("custom-reasonix-model");
});
it("resume passes --model when configured", 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");
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, model: "resume-model" },
});
const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : "";
expect(capturedArgs).toContain("--model");
expect(capturedArgs).toContain("resume-model");
});
it("resume sends a complete continued plan for task-level reuse", 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");
await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "",
purpose: "task_plan",
handle: { kind: "reasonix", sessionFilePath: sessionFile },
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
const capturedArgs = fs.readFileSync(argsFile, "utf8");
expect(capturedArgs).toContain("# Continued Task Plan: Test plan");
expect(capturedArgs).toContain("Do something.");
expect(capturedArgs).toContain("Tests pass");
expect(capturedArgs).not.toContain("# Fix Request");
});
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, -p, --verbose, and --output-format flags", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = writeFakeCli(tmpDir, {
argsFile,
stdout: '{"session_id": "test-session-uuid"}',
});
const adapter = createExecutorAdapter("claude");
let activityCount = 0;
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
onActivity: () => { activityCount += 1; },
});
const capturedArgs = fs.existsSync(argsFile)
? fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean)
: [];
expect(capturedArgs).toContain("-p");
expect(capturedArgs).toContain("--verbose");
const outputFormatIdx = capturedArgs.indexOf("--output-format");
expect(outputFormatIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[outputFormatIdx + 1]).toBe("stream-json");
const sessionIdIdx = capturedArgs.indexOf("--session-id");
expect(sessionIdIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[sessionIdIdx + 1]).toMatch(/^[0-9a-f-]{36}$/i);
expect(activityCount).toBeGreaterThan(0);
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").split("\0").filter(Boolean)
: [];
expect(capturedArgs).toContain("-p");
expect(capturedArgs).toContain("--verbose");
const outputFormatIdx = capturedArgs.indexOf("--output-format");
expect(outputFormatIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[outputFormatIdx + 1]).toBe("stream-json");
const resumeIdx = capturedArgs.indexOf("--resume");
expect(resumeIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[resumeIdx + 1]).toBe("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");
}
});
});
// ---------------------------------------------------------------------------
// Claude stream-json activity derivation (pure unit tests via exported function)
// ---------------------------------------------------------------------------
describe("deriveClaudeActivity (stream-json parsing)", () => {
let parseLine: (line: string) => string | undefined;
beforeAll(async () => {
const mod = await import("./workflow-executor.js");
parseLine = mod.parseClaudeActivityLine;
});
it("extracts tool_use from a type=assistant record with message.content and file_path", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"I'll read the file first"},{"type":"tool_use","name":"Read","input":{"file_path":"src/foo.ts"}}]}}`;
expect(parseLine(line)).toBe("Reading src/foo.ts");
});
it("extracts tool_use from a type=assistant record with command", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"npm test"}}]}}`;
expect(parseLine(line)).toBe("Running: npm test");
});
it("does NOT surface thinking content in activity summary", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"secret reasoning"},{"type":"tool_use","name":"Read","input":{"file_path":"src/ts"}}]}}`;
const result = parseLine(line);
expect(result).toBe("Reading src/ts");
expect(result).not.toContain("secret reasoning");
// A line with only thinking should return undefined
const thinkingOnly = `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hidden"}]}}`;
expect(parseLine(thinkingOnly)).toBeUndefined();
});
it("extracts capitalized tool names: Write, Edit, Glob, Grep", () => {
expect(parseLine(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"new.ts"}}]}}`)).toBe("Writing new.ts");
expect(parseLine(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"edit.ts"}}]}}`)).toBe("Editing edit.ts");
expect(parseLine(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Glob"}]}}`)).toBe("Searching files");
expect(parseLine(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"foo"}}]}}`)).toBe("Searching code");
});
it("extracts type=system subtype=init as session", () => {
expect(parseLine(`{"type":"system","subtype":"init","session_id":"abc123"}`)).toBe("Creating session");
});
it("extracts is_error result as error", () => {
expect(parseLine(`{"type":"result","is_error":true,"result":"Rate limit exceeded","session_id":"abc"}`)).toBe("Error: Rate limit exceeded");
});
it("extracts type=result at top level", () => {
expect(parseLine(`{"type":"result","result":"Tool ran without output"}`)).toBe("Result: Tool ran without output");
});
it("extracts type=error at top level", () => {
expect(parseLine(`{"type":"error","error":"Permission denied"}`)).toBe("Error: Permission denied");
});
it("returns undefined for non-JSON lines", () => {
expect(parseLine("implemented")).toBeUndefined();
expect(parseLine("")).toBeUndefined();
});
it("returns undefined for unknown types", () => {
expect(parseLine(`{"type":"ping"}`)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// End-to-end fake Claude stream-json fixture (adapter start and resume)
// ---------------------------------------------------------------------------
describe("ClaudeAdapter stream-json fixture", () => {
let tmpDir: string;
let previousHome: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-claude-fixture-"));
previousHome = process.env.HOME;
process.env.HOME = tmpDir;
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
cleanupDir(tmpDir);
});
/** Create a fake Claude binary that outputs stream-json lines. */
function makeFakeClaude(lines: string[], exitCode = 0): string {
const scriptPath = path.join(tmpDir, "fake-claude.sh");
const escaped = lines.map((l) => l.replace(/'/g, "'\\''")).join("\\n");
fs.writeFileSync(scriptPath, `#!/bin/bash
printf '${escaped}\\n'
exit ${exitCode}
`, { mode: 0o755 });
return scriptPath;
}
it("start: emits --output-format stream-json, captures session, has tool activity in sidecar", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = makeFakeClaude([
'{"type":"system","subtype":"init","session_id":"fixture-session-456"}',
'{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"thinking text hidden"},{"type":"tool_use","name":"Read","input":{"file_path":"src/index.ts"}}]}}',
'{"type":"result","result":"ok"}',
]);
const trackingBin = path.join(tmpDir, "tracking-claude.sh");
fs.writeFileSync(trackingBin, `#!/bin/bash
printf '%s\\0' "$@" >> "${argsFile}"
"${fakeBin}" "$@"
`, { mode: 0o755 });
const adapter = createExecutorAdapter("claude");
const logFilePath = path.join(tmpDir, "claude-exec.log");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
logFilePath,
config: { binary: trackingBin },
});
// 1) --output-format stream-json, -p, --verbose were passed
const capturedArgs = fs.existsSync(argsFile)
? fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean)
: [];
expect(capturedArgs).toContain("-p");
expect(capturedArgs).toContain("--verbose");
const outputFormatIdx = capturedArgs.indexOf("--output-format");
expect(outputFormatIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[outputFormatIdx + 1]).toBe("stream-json");
// 2) Exact session preservation
if (result.sessionHandle?.kind === "claude") {
expect(result.sessionHandle.sessionId).toBe("fixture-session-456");
}
// 3) Tool activity in the completed sidecar (no thinking text)
const { readProgressSidecar } = await import("./workflow-state.js");
const sidecar = readProgressSidecar(tmpDir);
expect(sidecar).toBeDefined();
expect(sidecar!.activitySummary).toContain("Reading");
expect(sidecar!.activitySummary).not.toContain("thinking text hidden");
expect(sidecar!.phase).toBe("completed");
expect(sidecar!.executor).toBe("claude");
});
it("start with error: extracts failure reason and marks sidecar failed", async () => {
const fakeBin = makeFakeClaude([
'{"type":"system","subtype":"init","session_id":"err-session-789"}',
'{"type":"result","is_error":true,"result":"Rate limit exceeded","session_id":"err-session-789"}',
], 1);
const adapter = createExecutorAdapter("claude");
const logFilePath = path.join(tmpDir, "claude-error.log");
const result = await adapter.start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
logFilePath,
config: { binary: fakeBin },
});
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("Rate limit exceeded");
const { readProgressSidecar } = await import("./workflow-state.js");
const sidecar = readProgressSidecar(tmpDir);
expect(sidecar).toBeDefined();
expect(sidecar!.phase).toBe("failed");
});
it("resume: preserves session ID from the handle and passes expected args", async () => {
const argsFile = path.join(tmpDir, "args.txt");
const fakeBin = makeFakeClaude([
'{"type":"system","subtype":"init","session_id":"resumed-session-999"}',
'{"type":"result","result":"completed"}',
]);
const trackingBin = path.join(tmpDir, "tracking-claude.sh");
fs.writeFileSync(trackingBin, `#!/bin/bash
printf '%s\\0' "$@" >> "${argsFile}"
"${fakeBin}" "$@"
`, { mode: 0o755 });
const adapter = createExecutorAdapter("claude");
const logFilePath = path.join(tmpDir, "claude-resume.log");
const result = await adapter.resume({
repoRoot: tmpDir,
plan: makeValidPlan(),
acceptedFindings: "Fix the bug",
handle: { kind: "claude", sessionId: "resumed-session-999" },
runDir: tmpDir,
cycleIndex: 2,
logFilePath,
config: { binary: trackingBin },
});
const capturedArgs = fs.existsSync(argsFile)
? fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean)
: [];
expect(capturedArgs).toContain("-p");
expect(capturedArgs).toContain("--verbose");
const outputFormatIdx = capturedArgs.indexOf("--output-format");
expect(outputFormatIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[outputFormatIdx + 1]).toBe("stream-json");
const resumeIdx = capturedArgs.indexOf("--resume");
expect(resumeIdx).toBeGreaterThanOrEqual(0);
expect(capturedArgs[resumeIdx + 1]).toBe("resumed-session-999");
if (result.sessionHandle?.kind === "claude") {
expect(result.sessionHandle.sessionId).toBe("resumed-session-999");
}
expect(result.failed).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Agy adapter tests
// ---------------------------------------------------------------------------
describe("AgyAdapter", () => {
let tmpDir: string;
let previousHome: string | undefined;
beforeEach(() => {
tmpDir = makeTmpDir();
previousHome = process.env.HOME;
process.env.HOME = tmpDir;
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
cleanupDir(tmpDir);
});
it("start passes the complete prompt as the --print value and enables headless tools", 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 },
timeoutSeconds: 1800,
});
const capturedArgs = fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean);
const printIndex = capturedArgs.indexOf("--print");
expect(capturedArgs).toContain("--dangerously-skip-permissions");
expect(capturedArgs).toContain("--mode");
expect(capturedArgs[capturedArgs.indexOf("--mode") + 1]).toBe("accept-edits");
expect(capturedArgs).toContain("--print-timeout");
expect(capturedArgs[capturedArgs.indexOf("--print-timeout") + 1]).toBe("24h");
expect(capturedArgs).not.toContain("--");
expect(printIndex).toBe(capturedArgs.length - 2);
expect(capturedArgs[printIndex + 1]).toContain("# Implementation Task: Test plan");
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 },
timeoutSeconds: 900,
});
const capturedArgs = fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean);
expect(capturedArgs).toContain("--conversation");
expect(capturedArgs).toContain("known-conv-id");
expect(capturedArgs).toContain("--dangerously-skip-permissions");
expect(capturedArgs[capturedArgs.indexOf("--print-timeout") + 1]).toBe("24h");
expect(capturedArgs[capturedArgs.indexOf("--print") + 1]).toContain("# Fix Request: Test plan");
});
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");
});
it("captures the conversation ID from the Agy log", async () => {
const fakeBin = path.join(tmpDir, "fake-agy.sh");
fs.writeFileSync(fakeBin, `#!/bin/bash
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--log-file" ]]; then
shift
echo 'Print mode: conversation=12345678-1234-1234-1234-123456789abc, sending message' > "$1"
fi
shift
done
echo 'implemented'
`, { mode: 0o755 });
const result = await createExecutorAdapter("agy").start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({
kind: "agy",
conversationId: "12345678-1234-1234-1234-123456789abc",
degraded: false,
});
});
it("does not reuse a workspace conversation that predates start", async () => {
const cacheDir = path.join(tmpDir, ".gemini", "antigravity-cli", "cache");
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(path.join(cacheDir, "last_conversations.json"), JSON.stringify({
[tmpDir]: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
}));
const fakeBin = writeFakeCli(tmpDir, { stdout: "implemented" });
const result = await createExecutorAdapter("agy").start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.sessionHandle).toEqual({ kind: "agy", degraded: true });
});
it("treats headless permission denial as failure even with exit code zero", async () => {
const fakeBin = writeFakeCli(tmpDir, {
stdout: 'jetski: no output produced — a tool required the "command" permission that headless mode cannot prompt for, so it was auto-denied.',
});
const result = await createExecutorAdapter("agy").start({
repoRoot: tmpDir,
plan: makeValidPlan(),
runDir: tmpDir,
cycleIndex: 1,
config: { binary: fakeBin },
});
expect(result.exitCode).toBe(0);
expect(result.failed).toBe(true);
expect(result.failureReason).toContain("no output produced");
});
});
// ---------------------------------------------------------------------------
// 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/);
});
});