feat: add fake session adapter
This commit is contained in:
286
packages/session/src/fake.test.ts
Normal file
286
packages/session/src/fake.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user