import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { PromptEnvelope } from "@devflow/core"; import { DevflowError } from "@devflow/core"; import { FakeSessionAdapter } from "./fake.js"; const runId = "00000000-0000-4000-8000-000000000001"; const dedupKey = "a".repeat(64); const secondDedupKey = "b".repeat(64); function envelope(overrides: Partial = {}): PromptEnvelope { return { uuid: "00000000-0000-4000-8000-000000000010", runId, roleId: "implementer", phaseKey: "implement", attempt: 0, expectedArtifact: join(mkdtempSync(join(tmpdir(), "devflow-fake-artifact-")), "artifact.json"), expectedSchema: "dev/spec@1", dedupKey, instructions: "Build the artifact", ...overrides, }; } function makeFixtureRoot(): string { const root = mkdtempSync(join(tmpdir(), "devflow-fake-fixtures-")); const schemaDir = join(root, "dev", "spec@1"); mkdirSync(schemaDir, { recursive: true }); writeFileSync( join(schemaDir, "ok.json"), JSON.stringify({ summary: "Fake spec", requirements: [{ id: "REQ-1", description: "Write the file" }], acceptanceCriteria: ["File is written"], risks: [], }), ); return root; } async function waitForFile(path: string): Promise { const deadline = Date.now() + 500; while (Date.now() < deadline) { if (existsSync(path)) { return; } await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(`Timed out waiting for ${path}`); } async function collect(iterable: AsyncIterable): Promise { const items: T[] = []; for await (const item of iterable) { items.push(item); } return items; } describe("FakeSessionAdapter", () => { const tempRoots: string[] = []; afterEach(() => { for (const root of tempRoots.splice(0)) { rmSync(root, { recursive: true, force: true }); } }); it("writes the ok fixture for the prompt schema and records transcript chunks", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); const prompt = envelope(); tempRoots.push(join(prompt.expectedArtifact, "..")); await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({ promptId: dedupKey }); await waitForFile(prompt.expectedArtifact); expect(JSON.parse(readFileSync(prompt.expectedArtifact, "utf8"))).toMatchObject({ summary: "Fake spec", }); const chunks = await collect(adapter.capture(handle, 0n)); expect(chunks.map((chunk) => chunk.content).join("\n")).toContain( `[fake] received prompt ${prompt.uuid}; will write ${prompt.expectedArtifact} in 0ms`, ); expect(chunks.every((chunk, index) => chunk.seq === BigInt(index + 1))).toBe(true); }); it("refuses duplicate prompt dedup keys for the same session", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); const first = envelope(); const duplicate = envelope({ uuid: "00000000-0000-4000-8000-000000000011", dedupKey, }); tempRoots.push(join(first.expectedArtifact, ".."), join(duplicate.expectedArtifact, "..")); await adapter.sendPrompt(handle, first); await expect(adapter.sendPrompt(handle, duplicate)).rejects.toMatchObject({ code: "duplicate_prompt_dedup_key", }); await waitForFile(first.expectedArtifact); }); it("preserves prompt dedup history across crash and rebootstrap recovery", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); const crash = envelope({ dedupKey: "c".repeat(64), instructions: "Scenario: crash\nCrash", }); await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({ code: "prompt_send_transient", }); await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({ code: "duplicate_prompt_dedup_key", }); const ok = envelope({ dedupKey: "d".repeat(64) }); tempRoots.push(join(ok.expectedArtifact, "..")); await adapter.sendPrompt(handle, ok); await waitForFile(ok.expectedArtifact); await adapter.rebootstrap(handle); await expect(adapter.sendPrompt(handle, ok)).rejects.toMatchObject({ code: "duplicate_prompt_dedup_key", }); }); it("rejects prompts whose run or role do not match the session", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); await expect( adapter.sendPrompt( handle, envelope({ runId: "00000000-0000-4000-8000-000000000099", dedupKey: "e".repeat(64), }), ), ).rejects.toMatchObject({ code: "prompt_session_mismatch" }); await expect( adapter.sendPrompt( handle, envelope({ roleId: "reviewer", dedupKey: "f".repeat(64), }), ), ).rejects.toMatchObject({ code: "prompt_session_mismatch" }); }); it("fails sendPrompt immediately when an ok fixture is missing", async () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "devflow-empty-fake-fixtures-")); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); const prompt = envelope(); tempRoots.push(join(prompt.expectedArtifact, "..")); await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({ class: "fatal", code: "fake_fixture_missing", }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(existsSync(prompt.expectedArtifact)).toBe(false); }); it("supports invalid, timeout, and crash sentinel scenarios", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, }); const invalid = envelope({ dedupKey: secondDedupKey, instructions: "Scenario: invalid\nBuild an invalid artifact", }); tempRoots.push(join(invalid.expectedArtifact, "..")); await adapter.sendPrompt(handle, invalid); await waitForFile(invalid.expectedArtifact); expect(JSON.parse(readFileSync(invalid.expectedArtifact, "utf8"))).toEqual({ fake: "invalid", }); const timeout = envelope({ dedupKey: "c".repeat(64), instructions: "Scenario: timeout\nDo not write", }); tempRoots.push(join(timeout.expectedArtifact, "..")); await adapter.sendPrompt(handle, timeout); await new Promise((resolve) => setTimeout(resolve, 20)); expect(existsSync(timeout.expectedArtifact)).toBe(false); const crash = envelope({ dedupKey: "d".repeat(64), instructions: "Scenario: crash\nCrash", }); await expect(adapter.sendPrompt(handle, crash)).rejects.toBeInstanceOf(DevflowError); await expect( adapter.sendPrompt(handle, { ...crash, dedupKey: "e".repeat(64), }), ).rejects.toMatchObject({ class: "recoverable", code: "prompt_send_transient", }); }); it("probes, resumes, rebootstraps, captures from a sequence, and disposes sessions", async () => { const fixtureRoot = makeFixtureRoot(); tempRoots.push(fixtureRoot); const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 }); const handle = await adapter.start({ runId, roleId: "implementer", backend: "fake", cwd: fixtureRoot, envelopePrelude: "Follow the fake protocol", }); expect(await adapter.resume(handle)).toEqual(handle); expect(await adapter.probe(handle)).toMatchObject({ alive: true, paneActive: true }); const rebootstrapped = await adapter.rebootstrap(handle); expect(rebootstrapped.sessionId).toBe(handle.sessionId); expect(await collect(adapter.capture(handle, 1n))).toEqual( expect.arrayContaining([ expect.objectContaining({ seq: 2n, content: "[fake] rebootstrap complete", }), ]), ); await adapter.dispose(handle); expect(await adapter.probe(handle)).toMatchObject({ alive: false, paneActive: false }); }); });