287 lines
9.2 KiB
TypeScript
287 lines
9.2 KiB
TypeScript
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> = {}): 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<void> {
|
|
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<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
|
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 });
|
|
});
|
|
});
|