891 lines
32 KiB
TypeScript
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/);
|
|
});
|
|
});
|