feat: add fake phase harness
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user