feat: add fake session adapter

This commit is contained in:
chungyeong
2026-05-10 01:27:43 +09:00
parent 1338e72e96
commit 017528b497
12 changed files with 695 additions and 2 deletions

View File

@@ -0,0 +1,17 @@
{
"name": "@devflow/session",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"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"
},
"dependencies": {
"@devflow/core": "workspace:*"
}
}

View File

@@ -0,0 +1,41 @@
import type { Backend, PromptEnvelope } from "@devflow/core";
export interface SessionAdapter {
start(input: StartInput): Promise<SessionHandle>;
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
probe(handle: SessionHandle): Promise<ProbeResult>;
resume(handle: SessionHandle): Promise<SessionHandle>;
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
dispose(handle: SessionHandle): Promise<void>;
}
export interface StartInput {
runId: string;
roleId: string;
backend: Backend;
cwd: string;
expectedArtifactPath?: string;
expectedSchema?: string;
envelopePrelude?: string;
}
export interface SessionHandle {
sessionId: string;
pid?: number;
tmuxSession?: string;
tmuxWindow?: string;
}
export interface ProbeResult {
alive: boolean;
paneActive: boolean;
lastOutputAt?: Date;
hint?: string;
}
export interface TranscriptChunk {
seq: bigint;
content: string;
capturedAt: Date;
}

View 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 });
});
});

View File

@@ -0,0 +1,295 @@
import { randomUUID } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { DevflowError, type PromptEnvelope } from "@devflow/core";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
export interface FakeSessionAdapterOptions {
fixtureRoot?: string;
writeDelayMs?: number;
sessionIdFactory?: () => string;
now?: () => Date;
}
interface FakeSessionRecord {
handle: SessionHandle;
runId: string;
roleId: string;
alive: boolean;
disposed: boolean;
transcript: TranscriptChunk[];
sentDedupKeys: Set<string>;
timers: Set<NodeJS.Timeout>;
lastOutputAt?: Date;
}
export class FakeSessionAdapter implements SessionAdapter {
private readonly fixtureRoot: string;
private readonly writeDelayMs: 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;
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
}
async start(input: StartInput): Promise<SessionHandle> {
if (input.backend !== "fake") {
throw new DevflowError("FakeSessionAdapter only supports the fake backend", {
class: "fatal",
code: "backend_unavailable",
runId: input.runId,
});
}
const handle: SessionHandle = { sessionId: this.sessionIdFactory() };
const record: FakeSessionRecord = {
handle,
runId: input.runId,
roleId: input.roleId,
alive: true,
disposed: false,
transcript: [],
sentDedupKeys: new Set(),
timers: new Set(),
};
this.sessions.set(handle.sessionId, record);
this.appendTranscript(record, `[fake] session started for ${input.roleId} in ${input.cwd}`);
return handle;
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
const record = this.requireLiveSession(handle);
if (envelope.runId !== record.runId || envelope.roleId !== record.roleId) {
throw new DevflowError("Prompt does not match fake session run or role", {
class: "fatal",
code: "prompt_session_mismatch",
runId: envelope.runId,
});
}
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,
});
}
const scenarioName = scenarioFromInstructions(envelope.instructions);
record.sentDedupKeys.add(envelope.dedupKey);
if (scenarioName === "crash") {
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; crashing`);
throw new DevflowError("Fake session crash scenario", {
class: "recoverable",
code: "prompt_send_transient",
runId: envelope.runId,
});
}
if (scenarioName === "timeout") {
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; timeout`);
return { promptId: envelope.dedupKey };
}
const fixturePath =
scenarioName === "invalid"
? undefined
: resolveFixturePath(
this.fixtureRoot,
envelope.expectedSchema,
scenarioName,
envelope.runId,
);
this.appendTranscript(
record,
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${this.writeDelayMs}ms`,
);
const timer = setTimeout(() => {
record.timers.delete(timer);
if (!record.alive || record.disposed) {
return;
}
try {
if (scenarioName === "invalid") {
writeJsonArtifact(envelope.expectedArtifact, { fake: "invalid" });
} else {
copyFixtureArtifact(fixturePath, envelope.expectedArtifact);
}
} catch (cause) {
record.alive = false;
this.appendTranscript(
record,
`[fake] failed to write artifact ${envelope.expectedArtifact}`,
);
return;
}
this.appendTranscript(record, `[fake] wrote artifact ${envelope.expectedArtifact}`);
}, this.writeDelayMs);
record.timers.add(timer);
return { promptId: envelope.dedupKey };
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined || !record.alive || record.disposed) {
return { alive: false, paneActive: false, hint: "fake session is not active" };
}
const result: ProbeResult = { alive: true, paneActive: true };
if (record.lastOutputAt !== undefined) {
return { ...result, lastOutputAt: record.lastOutputAt };
}
return result;
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
return this.requireLiveSession(handle).handle;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
throw new DevflowError("Cannot rebootstrap unknown fake session", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
for (const timer of record.timers) {
clearTimeout(timer);
}
record.timers.clear();
record.alive = true;
record.disposed = false;
this.appendTranscript(record, "[fake] rebootstrap complete");
return record.handle;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
return;
}
for (const chunk of record.transcript) {
if (chunk.seq > fromSeq) {
yield chunk;
}
}
}
async dispose(handle: SessionHandle): Promise<void> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
return;
}
for (const timer of record.timers) {
clearTimeout(timer);
}
record.timers.clear();
record.alive = false;
record.disposed = true;
}
private requireLiveSession(handle: SessionHandle): FakeSessionRecord {
const record = this.sessions.get(handle.sessionId);
if (record === undefined || !record.alive || record.disposed) {
throw new DevflowError("Fake session is not active", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
return record;
}
private appendTranscript(record: FakeSessionRecord, content: string): void {
const capturedAt = this.now();
record.lastOutputAt = capturedAt;
record.transcript.push({
seq: BigInt(record.transcript.length + 1),
content,
capturedAt,
});
}
}
function scenarioFromInstructions(instructions: string): string {
const match = /^Scenario:\s*([A-Za-z0-9_-]+)\s*$/m.exec(instructions);
return match?.[1] ?? "ok";
}
function resolveFixturePath(
fixtureRoot: string,
expectedSchema: string,
scenarioName: string,
runId: string,
): string {
const fixturePath = join(fixtureRoot, expectedSchema, `${scenarioName}.json`);
if (!existsSync(fixturePath) || !statSync(fixturePath).isFile()) {
throw new DevflowError(`Missing fake artifact fixture ${fixturePath}`, {
class: "fatal",
code: "fake_fixture_missing",
runId,
});
}
return fixturePath;
}
function copyFixtureArtifact(fixturePath: string | undefined, expectedArtifact: string): void {
if (fixturePath === undefined) {
throw new DevflowError("Missing resolved fake artifact fixture path", {
class: "fatal",
code: "fake_fixture_missing",
});
}
mkdirSync(dirname(expectedArtifact), { recursive: true });
copyFileSync(fixturePath, expectedArtifact);
}
function writeJsonArtifact(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value));
}
function defaultFixtureRoot(): string {
const workspaceRoot = findWorkspaceRoot(process.cwd());
return join(workspaceRoot, "tests", "fixtures", "fake-artifacts");
}
function findWorkspaceRoot(start: string): string {
let current = resolve(start);
while (true) {
const packageJsonPath = join(current, "package.json");
const workspacePath = join(current, "pnpm-workspace.yaml");
if (existsSync(packageJsonPath) && existsSync(workspacePath)) {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
if (packageJson.name === "devflow") {
return current;
}
}
const parent = dirname(current);
if (parent === current) {
return resolve(start);
}
current = parent;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./adapter.js";
export * from "./fake.js";

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"references": [],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../core" }]
}