feat: add fake phase harness

This commit is contained in:
chungyeong
2026-05-10 16:48:52 +09:00
parent be0ddb6e4e
commit 64efeabd33
22 changed files with 5766 additions and 76 deletions

View File

@@ -9,7 +9,7 @@
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "vitest run"
"test": "cd ../.. && vitest run --project packages/session"
},
"dependencies": {
"@devflow/core": "workspace:*"

View File

@@ -97,7 +97,25 @@ describe("FakeSessionAdapter", () => {
expect(chunks.every((chunk, index) => chunk.seq === BigInt(index + 1))).toBe(true);
});
it("refuses duplicate prompt dedup keys for the same session", async () => {
it("classifies unsupported backend as human-required backend_unavailable", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
await expect(
adapter.start({
runId,
roleId: "implementer",
backend: "codex",
cwd: fixtureRoot,
}),
).rejects.toMatchObject({
class: "human_required",
code: "backend_unavailable",
});
});
it("treats duplicate prompt dedup keys as idempotent success without reprocessing", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
@@ -116,13 +134,12 @@ describe("FakeSessionAdapter", () => {
await adapter.sendPrompt(handle, first);
await expect(adapter.sendPrompt(handle, duplicate)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
});
await expect(adapter.sendPrompt(handle, duplicate)).resolves.toEqual({ promptId: dedupKey });
await waitForFile(first.expectedArtifact);
expect(existsSync(duplicate.expectedArtifact)).toBe(false);
});
it("preserves prompt dedup history across crash and rebootstrap recovery", async () => {
it("records dedup history only after a fake prompt is accepted", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
@@ -141,7 +158,7 @@ describe("FakeSessionAdapter", () => {
code: "prompt_send_transient",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
code: "prompt_send_transient",
});
const ok = envelope({ dedupKey: "d".repeat(64) });
@@ -150,9 +167,7 @@ describe("FakeSessionAdapter", () => {
await waitForFile(ok.expectedArtifact);
await adapter.rebootstrap(handle);
await expect(adapter.sendPrompt(handle, ok)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
});
await expect(adapter.sendPrompt(handle, ok)).resolves.toEqual({ promptId: "d".repeat(64) });
});
it("rejects prompts whose run or role do not match the session", async () => {
@@ -207,6 +222,26 @@ describe("FakeSessionAdapter", () => {
expect(existsSync(prompt.expectedArtifact)).toBe(false);
});
it("does not record dedup history when fixture resolution fails", 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 missing = envelope({ dedupKey: "f".repeat(64) });
await expect(adapter.sendPrompt(handle, missing)).rejects.toMatchObject({
code: "fake_fixture_missing",
});
await expect(adapter.sendPrompt(handle, missing)).rejects.toMatchObject({
code: "fake_fixture_missing",
});
});
it("supports invalid, timeout, and crash sentinel scenarios", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);

View File

@@ -21,7 +21,7 @@ import type {
export interface FakeSessionAdapterOptions {
fixtureRoot?: string;
writeDelayMs?: number;
writeDelayMs?: number | ((envelope: PromptEnvelope) => number);
sessionIdFactory?: () => string;
now?: () => Date;
}
@@ -40,14 +40,16 @@ interface FakeSessionRecord {
export class FakeSessionAdapter implements SessionAdapter {
private readonly fixtureRoot: string;
private readonly writeDelayMs: number;
private readonly writeDelayMs: (envelope: PromptEnvelope) => number;
private readonly sessionIdFactory: () => string;
private readonly now: () => Date;
private readonly sessions = new Map<string, FakeSessionRecord>();
constructor(options: FakeSessionAdapterOptions = {}) {
this.fixtureRoot = options.fixtureRoot ?? defaultFixtureRoot();
this.writeDelayMs = options.writeDelayMs ?? 50;
const writeDelayMs = options.writeDelayMs;
this.writeDelayMs =
typeof writeDelayMs === "function" ? writeDelayMs : () => writeDelayMs ?? 50;
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
}
@@ -55,7 +57,7 @@ export class FakeSessionAdapter implements SessionAdapter {
async start(input: StartInput): Promise<SessionHandle> {
if (input.backend !== "fake") {
throw new DevflowError("FakeSessionAdapter only supports the fake backend", {
class: "fatal",
class: "human_required",
code: "backend_unavailable",
runId: input.runId,
});
@@ -87,15 +89,10 @@ export class FakeSessionAdapter implements SessionAdapter {
});
}
if (record.sentDedupKeys.has(envelope.dedupKey)) {
throw new DevflowError("Duplicate prompt dedup key refused by fake session", {
class: "recoverable",
code: "duplicate_prompt_dedup_key",
runId: record.runId,
});
return { promptId: envelope.dedupKey };
}
const scenarioName = scenarioFromInstructions(envelope.instructions);
record.sentDedupKeys.add(envelope.dedupKey);
if (scenarioName === "crash") {
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; crashing`);
@@ -107,6 +104,7 @@ export class FakeSessionAdapter implements SessionAdapter {
}
if (scenarioName === "timeout") {
record.sentDedupKeys.add(envelope.dedupKey);
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; timeout`);
return { promptId: envelope.dedupKey };
}
@@ -121,9 +119,12 @@ export class FakeSessionAdapter implements SessionAdapter {
envelope.runId,
);
record.sentDedupKeys.add(envelope.dedupKey);
const writeDelayMs = this.writeDelayMs(envelope);
this.appendTranscript(
record,
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${this.writeDelayMs}ms`,
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${writeDelayMs}ms`,
);
const timer = setTimeout(() => {
record.timers.delete(timer);
@@ -145,7 +146,7 @@ export class FakeSessionAdapter implements SessionAdapter {
return;
}
this.appendTranscript(record, `[fake] wrote artifact ${envelope.expectedArtifact}`);
}, this.writeDelayMs);
}, writeDelayMs);
record.timers.add(timer);
return { promptId: envelope.dedupKey };