feat(genbi): add gated Codex conversation lifecycle bridge

This commit is contained in:
Jax Liu
2026-09-10 16:58:23 +08:00
parent 86d0459b83
commit a21ee90c3f
5 changed files with 553 additions and 1 deletions
@@ -466,6 +466,7 @@ async function verifyInstalledCodexBackend(input) {
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { CodexAppServerBackend } from ${JSON.stringify(moduleUrl("codex-app-server"))};
import { CodexConversation } from ${JSON.stringify(moduleUrl("codex-conversation"))};
import { CODEX_CERTIFIED_ROWS } from ${JSON.stringify(moduleUrl("codex-compatibility"))};
import { CodexSession } from ${JSON.stringify(moduleUrl("codex-session"))};
assert.equal(CODEX_CERTIFIED_ROWS.length, 0);
@@ -474,6 +475,29 @@ async function verifyInstalledCodexBackend(input) {
assert.notEqual(readiness.state, 'ready');
assert.throws(() => backend.prepareLaunch());
assert.equal(existsSync(${JSON.stringify(runtimeRoot)}), false);
let bridgeClosed = 0;
const bridgeFrames = [];
const bridge = new CodexConversation({ open: async (_permit, input) => ({
startThread: async () => 'fixture-thread',
runTurn: async (text) => {
assert.equal(text, 'fixture prompt');
const turn = { id: 'fixture-turn', status: 'completed', items: [] };
input.onEvent({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn } });
return turn;
},
interruptTurn: async () => {},
onFailure: () => () => {},
close: async () => { bridgeClosed++; },
}) }, { assertActive() {}, release() {}, runtime: {} }, { spec: {}, wrenHome: {}, assertScopeActive() {} });
await bridge.ready;
const attachment = bridge.attach(bridge.capability, frame => bridgeFrames.push(frame));
assert.deepEqual(await attachment.submit('fixture prompt'), { turnId: 'fixture-turn', status: 'completed' });
assert.equal(bridgeFrames[0].type, 'replay');
assert.equal(bridgeFrames.some(frame => frame.type === 'event'), true);
attachment.detach();
assert.throws(() => attachment.submit('stale'));
await bridge.close();
assert.equal(bridgeClosed, 1);
const messages = []; let handlers; let closes = 0; const events = [];
const transport = {
listen(value) { handlers = value; },
@@ -514,7 +538,7 @@ function assertPublishedFiles(files) {
const forbidden = /(^|\/)(scripts|test|tests|fixtures|examples|\.git)(\/|$)|(^|\/)node_modules(\/|$)/;
const unexpected = files.filter((file) => forbidden.test(file));
if (unexpected.length > 0) throw new Error(`package tarball contains repository-only files: ${JSON.stringify(unexpected)}`);
for (const required of ["package/bin/genbi.mjs", "package/dist/index.html", "package/dist-server/server/bin.js", "package/managed-wren/manifest.json", "package/dist-server/server/runtime-host/codex-app-server.js", "package/dist-server/server/runtime-host/codex-session.js"]) {
for (const required of ["package/bin/genbi.mjs", "package/dist/index.html", "package/dist-server/server/bin.js", "package/managed-wren/manifest.json", "package/dist-server/server/runtime-host/codex-app-server.js", "package/dist-server/server/runtime-host/codex-session.js", "package/dist-server/server/runtime-host/codex-conversation.js"]) {
if (!files.includes(required)) throw new Error(`package tarball is missing ${required}`);
}
}
+39
View File
@@ -104,6 +104,45 @@ integration and browser event UI remain later composition work.
## Direct events versus command/PTY
`CodexConversation` is the process-local Sessions bridge above this driver.
The host passes the existing backend, a previously obtained single-use permit,
and its materialized scope/Wren-home inputs. Await `ready` before presenting an
interactive conversation; construction begins an asynchronous open and one
ephemeral thread, never a turn. The host must guard durable writes and scope
materialization with the permit before constructing the bridge.
An attachment requires the host-generated capability and grants only prompt,
interrupt and detach operations. One attachment controls a conversation at a
time; detached handles lose authority even when another attachment reconnects.
Detach does not cancel an already accepted turn. The existing Sessions owner
must enforce its initial-attachment/detach lease and invoke/await `close()`.
Do not persist or log capabilities, backend inputs, or driver objects.
Replay starts with explicit cursor/truncation/state metadata, followed by
strictly sequenced event/state frames. Retention is process-local, limited to
256 frames and 1 MiB of serialized UTF-8; it is not durable history or a complete
snapshot. Consumers must show truncation and must not assume retained deltas
contain the start of an item. Listener payloads are isolated copies. Listener
failure closes the owned backend; browser transport backpressure must be
bounded separately by the API/WebSocket owner. Vendor transcript content remains
untrusted user-facing data, never diagnostic text.
Only one turn may run at once. Interrupt waits for turn completion, not merely
the interrupt acknowledgement. Before a turn ID exists, cancellation closes the
connection instead of guessing an ID. Close also settles the bridge's pending
turn if the underlying driver has not yet settled it; cleanup failure is still
reported and generation retention remains the backend's responsibility.
Idle transport failure is observed through `CodexSession.onFailure()`.
Command/PTY events are rejected by this bridge, not rendered as conversation
text. After close, a capability can read the retained tail only if the enclosing
Sessions authorization still permits it; revocation and durable lifecycle state
belong to that owner.
This bridge is not registered in the current application composition. Scoped
producer/Wren/MCP materialization, durable Sessions/API/UI wiring, and structured
Ask/Setup injection still need their own acceptance before activation. In
particular, it does not enable MCP or bypass the project `.codex` rejection.
`startThread()` creates one ephemeral thread. `runTurn(text, { timeoutMs, signal })`
resolves on `turn/completed`, **not** the start acknowledgement. Turn statuses are
`completed`, `interrupted`, or `failed`; raw vendor error details are omitted.
@@ -0,0 +1,256 @@
import { randomUUID, timingSafeEqual } from "node:crypto";
import { CodexBackendError, type CodexAppServerBackend, type CodexLaunchPermit } from "./codex-app-server.js";
import { parseCodexEvent, type CodexEvent } from "./codex-events.js";
import { CodexRpcError } from "./codex-rpc.js";
import type { CodexSession } from "./codex-session.js";
import type { RuntimeBackendReasonCode } from "./types.js";
export type ConversationState = "opening" | "ready" | "running" | "closing" | "closed" | "failed";
export type ConversationFailure = "unavailable" | "protocol" | "timeout" | "cancelled" | "cleanup" | "delivery";
export class CodexConversationError extends Error {
constructor(readonly code: ConversationFailure | "attachment" | "busy" | "input" | "closed",
readonly backendReason?: RuntimeBackendReasonCode<"codex-app-server">) {
super("Codex conversation: " + code);
}
}
type ConversationEvent = Exclude<CodexEvent, { method: "command/exec/outputDelta" | "remoteControl/status/changed" | "thread/started" }>;
export type ConversationFrame =
| { readonly type: "event"; readonly sequence: number; readonly event: ConversationEvent }
| { readonly type: "state"; readonly sequence: number; readonly state: ConversationState; readonly failure?: ConversationFailure; readonly backendReason?: RuntimeBackendReasonCode<"codex-app-server"> };
export interface ConversationReplay {
readonly type: "replay";
readonly afterSequence: number;
readonly throughSequence: number;
readonly truncated: boolean;
readonly retainedBytes: number;
readonly retentionLimitBytes: number;
readonly state: ConversationState;
readonly failure?: ConversationFailure;
readonly backendReason?: RuntimeBackendReasonCode<"codex-app-server">;
}
export interface CodexConversationAttachment {
submit(text: string): Promise<{ readonly turnId: string; readonly status: "completed" | "interrupted" | "failed" }>;
interrupt(): Promise<void>;
detach(): void;
}
type Driver = Pick<CodexSession, "startThread" | "runTurn" | "interruptTurn" | "close" | "onFailure">;
type OpenInput = Omit<Parameters<CodexAppServerBackend["open"]>[1], "onEvent">;
interface Backend { open(permit: CodexLaunchPermit, input: OpenInput & { onEvent(event: CodexEvent): void }): Promise<Driver> }
interface Attachment {
readonly listener: (frame: ConversationFrame | ConversationReplay) => void;
readonly queue: string[];
queuedBytes: number;
draining: boolean;
}
const MAX_BYTES = 1_048_576;
const MAX_FRAMES = 256;
function failure(error: unknown): ConversationFailure {
if (error instanceof CodexConversationError && ["protocol", "timeout", "cancelled", "cleanup", "delivery"].includes(error.code)) return error.code as ConversationFailure;
if (error instanceof CodexBackendError && error.code === "codex_app_server_cleanup_failed") return "cleanup";
if (error instanceof CodexRpcError) {
if (error.reason === "cleanup" || error.reason === "timeout" || error.reason === "cancelled") return error.reason;
return "protocol";
}
return "unavailable";
}
/**
* Process-local Phase 5 bridge, not an application activation path.
* The host obtains the permit before durable writes and owns scope materialization.
* Browser code receives only attachment methods and projected frames, never a driver.
*/
export class CodexConversation {
readonly capability = randomUUID();
readonly ready: Promise<void>;
private state: ConversationState = "opening";
private failed?: ConversationFailure;
private backendReason: RuntimeBackendReasonCode<"codex-app-server"> | undefined;
private sequence = 0;
private retainedBytes = 0;
private frames: { sequence: number; json: string; bytes: number }[] = [];
private owner: Attachment | undefined;
private driver?: Driver;
private readonly connection: Promise<Driver>;
private closing?: Promise<void>;
private detachFailure?: () => void;
private active: Promise<{ turnId: string; status: "completed" | "interrupted" | "failed" }> | undefined;
private interrupting: Promise<void> | undefined;
private turnStarted = false;
private rejectStopped!: (error: CodexConversationError) => void;
private readonly stopped = new Promise<never>((_, reject) => { this.rejectStopped = reject; });
constructor(backend: Backend, permit: CodexLaunchPermit, input: OpenInput) {
void this.stopped.catch(() => {});
// Defer open until ownership of both promises exists; synchronous fake peers
// and early events must obey the same lifecycle as the production backend.
this.connection = Promise.resolve().then(() => {
if (this.state !== "opening") throw new CodexConversationError("closed");
permit.assertActive();
return backend.open(permit, { ...input, onEvent: (event) => this.receive(event) });
}).then((driver) => {
this.driver = driver;
this.detachFailure = driver.onFailure((error) => {
if (this.state !== "closing" && this.state !== "closed" && this.state !== "failed") {
void this.finish(failure(error)).catch(() => {});
}
});
return driver;
}).catch((error: unknown) => {
if (error instanceof CodexBackendError) this.backendReason = error.code;
permit.release(); throw error;
});
this.ready = this.initialize();
void this.ready.catch(() => {});
}
snapshot(): { state: ConversationState; failure?: ConversationFailure; backendReason?: RuntimeBackendReasonCode<"codex-app-server">; sequence: number } {
return { state: this.state, ...(this.failed ? { failure: this.failed } : {}),
...(this.backendReason ? { backendReason: this.backendReason } : {}), sequence: this.sequence };
}
private async initialize(): Promise<void> {
try {
const driver = await this.connection;
if (this.state !== "opening") throw new CodexConversationError("closed");
await driver.startThread();
if (this.state !== "opening") throw new CodexConversationError("closed");
this.transition("ready");
if (this.snapshot().state !== "ready") throw new CodexConversationError("closed");
} catch (error) {
await this.finish(failure(error));
throw new CodexConversationError(this.failed ?? (this.closing ? "closed" : failure(error)), this.backendReason);
}
}
private receive(value: CodexEvent): void {
if (this.state === "closing" || this.state === "closed" || this.state === "failed") return;
try {
const event = parseCodexEvent(value.method, value.params);
if (event.method === "command/exec/outputDelta" || event.method === "remoteControl/status/changed" || event.method === "thread/started") {
throw new CodexConversationError("protocol");
}
if (event.method === "turn/started") this.turnStarted = true;
this.record({ type: "event", sequence: ++this.sequence, event });
} catch {
void this.finish("protocol").catch(() => {});
throw new CodexConversationError("protocol");
}
}
private transition(state: ConversationState, code?: ConversationFailure): void {
this.state = state;
if (code) this.failed = code;
this.record({ type: "state", sequence: ++this.sequence, state, ...(this.failed ? { failure: this.failed } : {}),
...(this.backendReason ? { backendReason: this.backendReason } : {}) });
}
private record(frame: ConversationFrame): void {
const json = JSON.stringify(frame);
const bytes = Buffer.byteLength(json);
if (bytes > MAX_BYTES) throw new CodexConversationError("protocol");
this.frames.push({ sequence: frame.sequence, json, bytes }); this.retainedBytes += bytes;
while (this.frames.length > MAX_FRAMES || this.retainedBytes > MAX_BYTES) {
this.retainedBytes -= this.frames.shift()!.bytes;
}
if (this.owner) {
if (this.owner.queue.length >= MAX_FRAMES + 1 || this.owner.queuedBytes + bytes > MAX_BYTES + 4096) { this.deliveryFailed(); return; }
this.owner.queue.push(json); this.owner.queuedBytes += bytes; this.drain(this.owner);
}
}
private deliveryFailed(): void {
this.owner = undefined;
void this.finish("delivery").catch(() => {});
}
private drain(owner: Attachment): void {
if (owner.draining) return;
owner.draining = true;
try {
// Reentrant live events append behind the captured replay, never inside it.
let delivered = 0;
while (this.owner === owner && owner.queue.length) {
if (++delivered > MAX_FRAMES * 2 + 1) { this.deliveryFailed(); break; }
const json = owner.queue.shift()!;
owner.queuedBytes -= Buffer.byteLength(json);
owner.listener(JSON.parse(json));
}
} catch { this.deliveryFailed(); }
finally { owner.draining = false; }
}
attach(capability: string, listener: Attachment["listener"], afterSequence = 0): CodexConversationAttachment {
if (typeof capability !== "string" || capability.length !== this.capability.length) throw new CodexConversationError("attachment");
const supplied = Buffer.from(capability);
const expected = Buffer.from(this.capability);
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected) || this.owner || typeof listener !== "function") throw new CodexConversationError("attachment");
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0 || afterSequence > this.sequence) throw new CodexConversationError("input");
const replay: ConversationReplay = { type: "replay", afterSequence, throughSequence: this.sequence,
truncated: afterSequence < (this.frames[0]?.sequence ?? 1) - 1,
retainedBytes: this.retainedBytes, retentionLimitBytes: MAX_BYTES, state: this.state,
...(this.failed ? { failure: this.failed } : {}), ...(this.backendReason ? { backendReason: this.backendReason } : {}) };
const queue = [JSON.stringify(replay), ...this.frames.filter((frame) => frame.sequence > afterSequence).map((frame) => frame.json)];
const owner: Attachment = { listener, queue, queuedBytes: queue.reduce((sum, json) => sum + Buffer.byteLength(json), 0), draining: false };
this.owner = owner;
const assertOwner = () => { if (this.owner !== owner) throw new CodexConversationError("attachment"); };
const handle = Object.freeze({
submit: (text: string) => { assertOwner(); return this.submit(text); },
interrupt: () => { assertOwner(); return this.interrupt(); },
detach: () => { if (this.owner === owner) this.owner = undefined; },
});
this.drain(owner);
return handle;
}
private submit(text: string): Promise<{ turnId: string; status: "completed" | "interrupted" | "failed" }> {
if (this.active) throw new CodexConversationError("busy");
if (this.state !== "ready" || !this.driver) throw new CodexConversationError("closed");
if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > 262_144) throw new CodexConversationError("input");
const driver = this.driver;
this.turnStarted = false;
const active = Promise.resolve().then(async () => {
if (this.state !== "running") throw new CodexConversationError("closed");
const result = await Promise.race([driver.runTurn(text), this.stopped]);
if (result.status === "inProgress") throw new CodexConversationError("protocol");
return { turnId: result.id, status: result.status };
}).catch(async (error: unknown) => {
await this.finish(failure(error));
throw new CodexConversationError(this.failed ?? (error instanceof CodexConversationError ? error.code : failure(error)));
}).finally(() => {
this.active = undefined; this.interrupting = undefined;
if (this.state === "running") this.transition("ready");
});
this.active = active;
void active.catch(() => {});
this.transition("running");
return active;
}
private interrupt(): Promise<void> {
if (!this.active || this.state !== "running" || !this.driver) throw new CodexConversationError("closed");
// Before turn/start is acknowledged there is no interruptible vendor ID.
// Closing the owned connection cancels that early window without guessing it.
if (!this.turnStarted) return this.finish("cancelled");
const active = this.active;
this.interrupting ??= this.driver.interruptTurn().then(async () => { await active; }).catch(async (error: unknown) => {
await this.finish(failure(error)); throw new CodexConversationError(this.failed ?? failure(error));
});
void this.interrupting.catch(() => {});
return this.interrupting;
}
/** Detach retains the conversation; the Sessions lease owner decides when to close. */
close(): Promise<void> { return this.finish(); }
private finish(code?: ConversationFailure): Promise<void> {
if (this.closing) return this.closing;
if (code) this.failed = code;
this.state = "closing";
this.rejectStopped(new CodexConversationError(code ?? "closed"));
this.closing = Promise.resolve().then(async () => {
try {
let driver: Driver | undefined;
try { driver = await this.connection; }
catch (error) { if (failure(error) === "cleanup") throw error; }
await driver?.close();
this.detachFailure?.();
this.transition(this.failed ? "failed" : "closed");
} catch {
this.backendReason = "codex_app_server_cleanup_failed";
this.detachFailure?.();
this.transition("failed", "cleanup");
throw new CodexConversationError("cleanup", this.backendReason);
} finally { this.owner = undefined; }
});
void this.closing.catch(() => {});
return this.closing;
}
}
@@ -262,6 +262,10 @@ export class CodexSession {
this.emit(event);
}
/** Disconnect and BFF shutdown use the same bounded, connection-owned close. */
onFailure(listener: (error: CodexRpcError) => void): () => void {
return this.rpc.onFailure(listener);
}
close(): Promise<void> {
this.closed = true;
this.closePromise ??= this.rpc.close();
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from "vitest";
import { CodexConversation, CodexConversationError, type ConversationFrame, type ConversationReplay } from "../server/runtime-host/codex-conversation.js";
import { CodexBackendError, type CodexLaunchPermit } from "../server/runtime-host/codex-app-server.js";
import { CodexRpcError } from "../server/runtime-host/codex-rpc.js";
import type { CodexEvent } from "../server/runtime-host/codex-events.js";
import type { CodexSession } from "../server/runtime-host/codex-session.js";
type Turn = Awaited<ReturnType<CodexSession["runTurn"]>>;
function deferred<T>() {
let resolve!: (value: T) => void; let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function fixture() {
let emit!: (event: CodexEvent) => void;
let failed: ((error: CodexRpcError) => void) | undefined;
const turn = deferred<Turn>();
const permit = { runtime: {}, assertActive: vi.fn(), release: vi.fn() } as unknown as CodexLaunchPermit;
const driver = {
startThread: vi.fn(async () => "thread"),
runTurn: vi.fn(() => turn.promise),
interruptTurn: vi.fn(async () => {}),
close: vi.fn(async () => { turn.reject(new CodexRpcError("closed")); }),
onFailure: vi.fn((listener: (error: CodexRpcError) => void) => { failed = listener; return () => { failed = undefined; }; }),
};
void turn.promise.catch(() => {});
const backend = { open: vi.fn(async (_permit: CodexLaunchPermit, input: { onEvent(event: CodexEvent): void }) => { emit = input.onEvent; return driver; }) };
const input = { spec: {}, wrenHome: {}, assertScopeActive: vi.fn() } as unknown as ConstructorParameters<typeof CodexConversation>[2];
const conversation = new CodexConversation(backend, permit, input);
return { conversation, driver, backend, permit, turn, input, emit: (event: CodexEvent) => emit(event), fail: (error: CodexRpcError) => failed?.(error) };
}
const status = (): CodexEvent => ({ method: "thread/status/changed", params: { threadId: "thread", status: { type: "idle" } } });
const started = (): CodexEvent => ({ method: "turn/started", params: { threadId: "thread", turn: { id: "turn", status: "inProgress", items: [] } } });
type Frame = ConversationFrame | ConversationReplay;
describe("Codex conversation lifecycle bridge", () => {
it("uses the supplied permit/open contract, captures early events and starts exactly one thread", async () => {
const f = fixture();
f.backend.open.mockImplementationOnce(async (_permit, input) => { input.onEvent(status()); return f.driver; });
await f.conversation.ready;
expect(f.permit.assertActive).toHaveBeenCalledOnce();
expect(f.backend.open.mock.calls[0]![0]).toBe(f.permit);
expect(f.backend.open.mock.calls[0]![1]).toMatchObject(f.input);
expect(f.driver.startThread).toHaveBeenCalledOnce();
const frames: Frame[] = [];
f.conversation.attach(f.conversation.capability, (frame) => frames.push(frame));
expect(frames.map((frame) => frame.type)).toEqual(["replay", "event", "state"]);
await f.conversation.close();
});
it("rejects wrong/cross-session capabilities and concurrent owners; stale handles cannot control a reattachment", async () => {
const f = fixture(); const other = fixture(); await Promise.all([f.conversation.ready, other.conversation.ready]);
expect(() => f.conversation.attach(other.conversation.capability, () => {})).toThrow("attachment");
const first = f.conversation.attach(f.conversation.capability, () => {});
expect(() => f.conversation.attach(f.conversation.capability, () => {})).toThrow("attachment");
first.detach();
const second = f.conversation.attach(f.conversation.capability, () => {});
first.detach();
expect(() => first.submit("old")).toThrow("attachment");
expect(() => first.interrupt()).toThrow("attachment");
expect(() => second.submit("")).toThrow("input");
expect(f.driver.runTurn).not.toHaveBeenCalled();
await Promise.all([f.conversation.close(), other.conversation.close()]);
});
it("serializes turns, streams events and does not mistake interrupt acknowledgement for completion", async () => {
const f = fixture(); await f.conversation.ready;
const frames: Frame[] = [];
const attachment = f.conversation.attach(f.conversation.capability, (frame) => frames.push(frame));
const submitted = attachment.submit("hello");
expect(() => attachment.submit("concurrent")).toThrow("busy");
await Promise.resolve(); f.emit(started());
const interrupted = attachment.interrupt();
let complete = false; void interrupted.then(() => { complete = true; });
await Promise.resolve(); await Promise.resolve();
expect(complete).toBe(false);
f.turn.resolve({ id: "turn", status: "interrupted", items: [] });
await expect(submitted).resolves.toEqual({ turnId: "turn", status: "interrupted" });
await interrupted;
expect(f.conversation.snapshot().state).toBe("ready");
expect(frames.some((frame) => frame.type === "event")).toBe(true);
await f.conversation.close();
});
it("cancels before the turn acknowledgement by closing, never by guessing an interrupt ID", async () => {
const f = fixture(); await f.conversation.ready;
const attachment = f.conversation.attach(f.conversation.capability, () => {});
const submitted = attachment.submit("hello");
await attachment.interrupt();
await expect(submitted).rejects.toBeInstanceOf(CodexConversationError);
expect(f.driver.interruptTurn).not.toHaveBeenCalled();
expect(f.driver.close).toHaveBeenCalledOnce();
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "cancelled" });
});
it("bounds replay by count and bytes, reports truncation and rejects future cursors", async () => {
const f = fixture(); await f.conversation.ready;
for (let i = 0; i < 300; i++) f.emit(status());
let frames: Frame[] = [];
const first = f.conversation.attach(f.conversation.capability, (frame) => frames.push(frame));
expect(frames).toHaveLength(257);
expect(frames[0]).toMatchObject({ type: "replay", truncated: true, throughSequence: 301 });
expect(() => { first.detach(); f.conversation.attach(f.conversation.capability, () => {}, 9999); }).toThrow("input");
const big: CodexEvent = { method: "item/agentMessage/delta", params: { threadId: "thread", turnId: "turn", itemId: "item", delta: "字".repeat(100_000) } };
for (let i = 0; i < 6; i++) f.emit(big);
frames = [];
f.conversation.attach(f.conversation.capability, (frame) => frames.push(frame));
expect(frames[0]).toMatchObject({ type: "replay", truncated: true });
expect((frames[0] as ConversationReplay).retainedBytes).toBeLessThanOrEqual(1_048_576);
await f.conversation.close();
});
it("reentrant live delivery follows the captured replay and subscriber mutation cannot corrupt retained frames", async () => {
const f = fixture(); await f.conversation.ready; f.emit(status());
const frames: Frame[] = [];
const first = f.conversation.attach(f.conversation.capability, (frame) => {
frames.push(frame);
if (frame.type === "replay") f.emit(status());
if (frame.type === "event") frame.event.params.threadId = "mutated";
});
expect(frames.map((frame) => frame.type === "replay" ? 0 : frame.sequence)).toEqual([0, 1, 2, 3]);
first.detach();
const replay: Frame[] = [];
f.conversation.attach(f.conversation.capability, (frame) => replay.push(frame), 1);
expect(replay[1]).toMatchObject({ event: { params: { threadId: "thread" } } });
await f.conversation.close();
});
it("detach preserves the active turn; host close settles work and forbids later operations", async () => {
const f = fixture(); await f.conversation.ready;
const attachment = f.conversation.attach(f.conversation.capability, () => {});
const submitted = attachment.submit("hello"); await Promise.resolve();
attachment.detach();
expect(f.driver.close).not.toHaveBeenCalled();
await f.conversation.close();
await expect(submitted).rejects.toBeInstanceOf(CodexConversationError);
expect(() => attachment.submit("late")).toThrow("attachment");
});
it("closes idle transport failures and redacts arbitrary errors", async () => {
const f = fixture(); await f.conversation.ready;
f.fail(new CodexRpcError("transport"));
await f.conversation.close();
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "protocol" });
expect(f.driver.close).toHaveBeenCalledOnce();
const g = fixture(); g.backend.open.mockRejectedValueOnce(new Error("private credential output"));
await expect(g.conversation.ready).rejects.toThrow("unavailable");
expect(JSON.stringify(g.conversation.snapshot())).not.toContain("private");
expect(g.permit.release).toHaveBeenCalledOnce();
});
it("reports cleanup failure and never retries it as successful termination", async () => {
const f = fixture(); await f.conversation.ready;
f.driver.close.mockRejectedValueOnce(new Error("private cleanup path"));
const close = f.conversation.close();
expect(f.conversation.close()).toBe(close);
await expect(close).rejects.toThrow("cleanup");
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "cleanup" });
expect(f.driver.close).toHaveBeenCalledOnce();
const g = fixture(); g.backend.open.mockRejectedValueOnce(new CodexBackendError("codex_app_server_cleanup_failed"));
await expect(g.conversation.ready).rejects.toThrow("cleanup");
await expect(g.conversation.close()).rejects.toThrow("cleanup");
});
it("callback failure closes the backend without forwarding the thrown value", async () => {
const f = fixture(); await f.conversation.ready;
f.conversation.attach(f.conversation.capability, () => { throw new Error("secret in listener"); });
await f.conversation.close();
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "delivery" });
expect(JSON.stringify(f.conversation.snapshot())).not.toContain("secret");
});
it("close before opening releases the permit without spawning or starting a thread", async () => {
const f = fixture(); await f.conversation.close();
await expect(f.conversation.ready).rejects.toThrow("closed");
expect(f.backend.open).not.toHaveBeenCalled();
expect(f.permit.release).toHaveBeenCalledOnce();
});
it("close during backend initialization owns and closes the eventual connection", async () => {
const f = fixture(); const opening = deferred<typeof f.driver>();
f.backend.open.mockReturnValueOnce(opening.promise);
await Promise.resolve(); await Promise.resolve();
const closing = f.conversation.close(); opening.resolve(f.driver);
await closing; await expect(f.conversation.ready).rejects.toThrow("closed");
expect(f.driver.close).toHaveBeenCalledOnce();
expect(f.driver.startThread).not.toHaveBeenCalled();
});
it("opening failure waits for cleanup already triggered by the transport failure hook", async () => {
const f = fixture(); const cleanup = deferred<void>();
f.driver.close.mockReturnValueOnce(cleanup.promise);
f.driver.startThread.mockImplementationOnce(async () => {
f.fail(new CodexRpcError("transport"));
throw new CodexRpcError("protocol");
});
let settled = false;
void f.conversation.ready.catch(() => { settled = true; });
await vi.waitFor(() => expect(f.driver.close).toHaveBeenCalledOnce());
expect(settled).toBe(false);
cleanup.resolve();
await expect(f.conversation.ready).rejects.toThrow("protocol");
});
it("stale permit fails before backend open and is released", async () => {
const f = fixture(); vi.mocked(f.permit.assertActive).mockImplementationOnce(() => { throw new Error("stale"); });
await expect(f.conversation.ready).rejects.toThrow("unavailable");
expect(f.backend.open).not.toHaveBeenCalled();
expect(f.permit.release).toHaveBeenCalledOnce();
});
it("preserves fixed per-backend reasons through opening failure and replay metadata", async () => {
const f = fixture(); f.backend.open.mockRejectedValueOnce(new CodexBackendError("codex_identity_uncertified"));
await expect(f.conversation.ready).rejects.toMatchObject({ code: "unavailable", backendReason: "codex_identity_uncertified" });
expect(f.conversation.snapshot()).toMatchObject({ backendReason: "codex_identity_uncertified" });
const frames: Frame[] = [];
f.conversation.attach(f.conversation.capability, (frame) => frames.push(frame));
expect(frames[0]).toMatchObject({ backendReason: "codex_identity_uncertified" });
});
it("refuses terminal bytes and unknown protocol events instead of presenting them as conversations", async () => {
const f = fixture(); await f.conversation.ready;
expect(() => f.emit({ method: "command/exec/outputDelta", params: { processId: "p", stream: "stdout", deltaBase64: "", capReached: false } })).toThrow("protocol");
await f.conversation.close();
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "protocol" });
const g = fixture(); await g.conversation.ready;
expect(() => g.emit({ method: "unknown", params: {} } as unknown as CodexEvent)).toThrow("protocol");
await g.conversation.close();
});
it("settles the facade turn even if the closing driver leaves its own completion pending", async () => {
const f = fixture(); await f.conversation.ready;
f.driver.close.mockImplementationOnce(async () => {});
const handle = f.conversation.attach(f.conversation.capability, () => {});
const submitted = handle.submit("hello"); await Promise.resolve();
await f.conversation.close();
await expect(submitted).rejects.toThrow("closed");
});
it("an oversized event fails closed instead of silently disappearing from replay", async () => {
const f = fixture(); await f.conversation.ready;
expect(() => f.emit({ method: "item/agentMessage/delta", params: { threadId: "thread", turnId: "turn", itemId: "item", delta: "字".repeat(400_000) } })).toThrow("protocol");
await f.conversation.close();
expect(f.conversation.snapshot()).toMatchObject({ state: "failed", failure: "protocol" });
});
});