/** * 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 ... ' 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/); }); });