Files
paseo/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
T
a48bc3aa6f Keep concurrent Codex discovery from failing during startup (#9008)
* Serialize Codex app-server startup by state directory

* Keep Codex session constructor unchanged

* Type Codex startup failures and inject process launch port

* fix(codex): retain startup diagnostics emitted after exit

Classify failed initialization after stdio closes so the bounded SQLite retry sees the complete stderr. Keep running-session exit handling immediate. The regression delivers the SQLite diagnostic between exit and close.

* test(codex): inject history test process through the public dependency

The history fixture still replaced the old private spawn method, so provider launch preparation tried to find a real CLI in CI. Inject the same process interface as other provider tests and use an invalid command as a guard against accidental real launches.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-11-07 14:03:47 +00:00

8215 lines
268 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import type {
AgentClient,
AgentLaunchContext,
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
import {
buildCodexAppServerEnv,
CodexAppServerAgentClient,
CodexAppServerAgentSession,
codexMicrosoftStoreBinaryCandidates,
codexAppServerTurnInputFromPrompt,
listCodexSkills,
mapCodexPatchNotificationToToolCall,
mapCodexPlanUpdateToTodo,
mapCodexPlanToToolCall,
normalizeCodexOutputSchema,
toAgentUsage,
} from "./codex-app-server-agent.js";
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
expect(
mapCodexPlanUpdateToTodo([
{ step: "Inspect", status: "completed" },
{ step: "Implement", status: "inProgress" },
{ step: "Verify", status: "pending" },
]),
).toEqual({
type: "todo",
items: [
{ id: "0", text: "Inspect", status: "completed", completed: true },
{ id: "1", text: "Implement", status: "in_progress", completed: false },
{ id: "2", text: "Verify", status: "pending", completed: false },
],
});
});
});
describe("Codex executable discovery", () => {
test("only considers sorted Codex Store packages", () => {
const entries = [
{ name: "Other.App_1", isDirectory: () => true },
{ name: "OpenAI.Codex_z", isDirectory: () => true },
{ name: "OpenAI.Codex_a", isDirectory: () => true },
{ name: "OpenAI.Codex_file", isDirectory: () => false },
] as unknown as Dirent[];
expect(codexMicrosoftStoreBinaryCandidates("C:\\Packages", entries)).toEqual([
path.join(
"C:\\Packages",
"OpenAI.Codex_a",
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
"codex.exe",
),
path.join(
"C:\\Packages",
"OpenAI.Codex_z",
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
"codex.exe",
),
]);
});
});
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextEvent,
waitForNextPermission,
waitForNextTimelineItem,
waitForProviderSubagent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
}
interface CodexSessionTestAccess {
codexUserMessageTurns(): {
resolve(messageId: string): { index: number; turnId: string | null } | null;
count(): number;
};
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): Promise<void>;
refreshResolvedCollaborationMode(): void;
serviceTier: "fast" | null;
planModeEnabled: boolean;
collaborationModes: CollaborationModeRecord[];
config: AgentSessionConfig;
}
interface CodexClientLike {
request: (method: string, ...rest: unknown[]) => Promise<unknown>;
}
type CodexTestSession = AgentSession & {
connectionState: "disconnected" | "history-ready" | "connected";
currentThreadId: string | null;
activeForegroundTurnId: string | null;
client: CodexClientLike | null;
};
type TurnTerminalEvent = Extract<
AgentStreamEvent,
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
>;
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const CODEX_PROVIDER = "codex";
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: CODEX_PROVIDER,
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
...overrides,
};
}
function createSession(
configOverrides: Partial<AgentSessionConfig> = {},
options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {},
): CodexTestSession {
const session = new CodexAppServerAgentSession(
createConfig(configOverrides),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
{},
false,
options.goalsEnabled === true,
options.autoReviewEnabled === true,
) as CodexTestSession;
session.connectionState = "connected";
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
}
function createProviderWithFakeAppServer(
appServer: FakeCodexAppServer,
options?: {
runtimeSettings?: ConstructorParameters<typeof CodexAppServerAgentClient>[1];
customProvider?: { id: string; label: string; extends: string };
},
): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
customProvider: options?.customProvider,
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
return provider;
}
function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) {
return {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => spawn(),
};
}
async function startPublicSteeringSession(
appServer: FakeCodexAppServer,
resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{
commandName: string;
args?: string;
} | null>,
): Promise<{ session: AgentSession; paseoTurnId: string }> {
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
{ resolveSlashCommandInvocation },
);
const started = await session.startTurn("first");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
return { session, paseoTurnId: started.turnId };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Codex file-change approvals", () => {
test("file-change approval includes the matching patch when notifications are still queued", async () => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
for (const [id, filePath, diff] of [
["file-alpha", "/workspace/project/alpha.txt", "@@ -1 +1 @@\n-blue\n+green\n"],
["file-beta", "/workspace/project/beta.txt", "@@ -1 +1 @@\n-small\n+large\n"],
]) {
appServer.startsFileChange({
threadId: "thread-1",
turnId: "native-A",
callId: id,
path: filePath,
diff,
});
}
const permission = waitForNextPermission(session);
appServer.requestFileChangeApproval({
itemId: "file-alpha",
threadId: "thread-1",
turnId: "native-A",
reason: "Apply the patch",
});
expect((await permission).request).toEqual({
id: "permission-file-alpha",
provider: "codex",
name: "CodexFileChange",
kind: "tool",
title: "Apply file changes: alpha.txt",
description: "Apply the patch",
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: "@@ -1 +1 @@\n-blue\n+green\n",
},
metadata: { itemId: "file-alpha", threadId: "thread-1", turnId: "native-A" },
});
await session.respondToPermission("permission-file-alpha", { behavior: "allow" });
await expect(appServer.waitForApprovalDecision("file-alpha")).resolves.toEqual({
decision: "accept",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
});
describe("Codex active-turn steering admission", () => {
test("steers an accepted turn whose native id arrives after startTurn returns", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
await expect(steer).resolves.toEqual({ status: "accepted" });
expect(
appServer.requests().filter((request) => request.method === "turn/start"),
).toHaveLength(1);
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
[],
);
expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject(
{
params: {
threadId: "thread-1",
expectedTurnId: "native-A",
input: [{ type: "text", text: "SECOND" }],
},
},
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("releases a waiting steer when the accepted turn ends before identification", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.completeTurn();
await expect(steer).resolves.toEqual({ status: "unavailable" });
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("a steer without the clearing contract leaves permissions open", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
castInternals<{ emitSyntheticPlanApprovalRequest: (planText: string) => void }>(
session,
).emitSyntheticPlanApprovalRequest("Ship the thing");
await expect(
session.steerActiveTurn!("background notification", { expectedTurnId: paseoTurnId }),
).resolves.toEqual({ status: "accepted" });
expect(session.getPendingPermissions()).toHaveLength(1);
const requestId = session.getPendingPermissions()[0]!.id;
await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" });
await session.close();
appServer.assertNoErrors();
});
test("a clearing steer denies every pending permission through its provider handler", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const commandPermission = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "command-1",
threadId: "thread-1",
turnId: "native-A",
command: "git status",
cwd: "/workspace/project",
reason: "Needs approval",
});
await commandPermission;
const filePermission = waitForNextPermission(session);
appServer.requestFileChangeApproval({
itemId: "file-1",
threadId: "thread-1",
turnId: "native-A",
reason: "Apply the patch",
});
await filePermission;
const questionPermission = waitForNextPermission(session);
appServer.requestUserInput({
itemId: "question-1",
threadId: "thread-1",
turnId: "native-A",
questions: [
{
id: "choice",
header: "Choice",
question: "Which option?",
options: [{ label: "One" }, { label: "Two" }],
},
],
});
await questionPermission;
const mcpPermission = waitForNextPermission(session);
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "native-A",
serverName: "browser",
message: "Open this page?",
requestedSchema: { type: "object", properties: {} },
});
await mcpPermission;
castInternals<{ emitSyntheticPlanApprovalRequest: (planText: string) => void }>(
session,
).emitSyntheticPlanApprovalRequest("Ship the thing");
expect(session.getPendingPermissions()).toHaveLength(5);
await expect(
session.steerActiveTurn!("review this instead", {
expectedTurnId: paseoTurnId,
clearPendingPermissions: true,
}),
).resolves.toEqual({ status: "accepted" });
expect(session.getPendingPermissions()).toEqual([]);
await expect(appServer.waitForApprovalDecision("command-1")).resolves.toEqual({
decision: "decline",
});
await expect(appServer.waitForApprovalDecision("file-1")).resolves.toEqual({
decision: "decline",
});
await expect(appServer.waitForApprovalDecision("question-1")).resolves.toEqual({ answers: {} });
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "decline",
content: null,
_meta: null,
});
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
name: "plan_approval",
detail: { type: "plan", text: "Ship the thing" },
metadata: expect.objectContaining({ approved: false }),
}),
}),
);
await session.close();
appServer.assertNoErrors();
});
test("does not steer B when A completes while command resolution is pending", async () => {
const commandResolution = deferred<{ commandName: string } | null>();
const resolverEntered = deferred<void>();
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer, async (prompt) => {
if (prompt !== "/held") return null;
resolverEntered.resolve();
return commandResolution.promise;
});
const steer = session.steerActiveTurn!("/held", {
expectedTurnId: paseoTurnId,
clientMessageId: "steer-A",
});
await resolverEntered.promise;
appServer.completeTurn({ threadId: "thread-1" });
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
const startedB = await session.startTurn("second");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
commandResolution.resolve(null);
await expect(steer).resolves.toEqual({ status: "unavailable" });
expect(startedB.turnId).not.toBe(paseoTurnId);
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
await session.close();
appServer.assertNoErrors();
});
test.each([
["method unavailable", -32601, "method not found", undefined, "unavailable"],
["no active turn", -32600, "no active turn to steer", undefined, "unavailable"],
[
"expected turn mismatch",
-32600,
"expected active turn id `native-A` but found `native-B`",
undefined,
"unavailable",
],
[
"output schema mismatch",
-32600,
"active turn uses a different output schema",
undefined,
"unavailable",
],
[
"review turn",
-32600,
"cannot steer a review turn",
{ codexErrorInfo: { activeTurnNotSteerable: { turnKind: "review" } } },
"unavailable",
],
["unknown invalid request", -32600, "input must not be empty", undefined, "throws"],
] as const)("classifies JSON-RPC $0", async (_name, code, message, data, expected) => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ __jsonRpcError: { code, message, ...(data ? { data } : {}) } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const steer = session.steerActiveTurn!("follow up", {
expectedTurnId: paseoTurnId,
clientMessageId: "steer-frame",
});
if (expected === "unavailable") {
await expect(steer).resolves.toEqual({ status: "unavailable" });
} else {
await expect(steer).rejects.toThrow(message);
}
await session.close();
appServer.assertNoErrors();
});
test("leaves a JSON-RPC server error ambiguous", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({
__jsonRpcError: { code: -32000, message: "connection lost" },
}),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
await expect(
session.steerActiveTurn!("follow up", {
expectedTurnId: paseoTurnId,
clientMessageId: "steer-transport",
}),
).rejects.toThrow("connection lost");
await session.close();
});
test("rejects an in-flight steer when the app-server transport disconnects", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => new Promise<void>(() => undefined),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const steer = session.steerActiveTurn!("follow up", {
expectedTurnId: paseoTurnId,
clientMessageId: "steer-disconnect",
});
await appServer.waitForRequest("turn/steer");
appServer.disconnect();
await expect(steer).rejects.toThrow("Codex app-server exited");
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
1,
);
await session.close();
});
});
async function startCompactionTurnTest(): Promise<{
appServer: FakeCodexAppServer;
session: AgentSession;
events: AgentStreamEvent[];
terminalEvent: Promise<TurnTerminalEvent>;
}> {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
const terminalEvent = new Promise<TurnTerminalEvent>((resolve) => {
session.subscribe((event) => {
const isCompaction = event.type === "timeline" && event.item.type === "compaction";
const isTerminal =
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled";
if (isCompaction || isTerminal) {
events.push(event);
}
if (isTerminal) {
resolve(event);
}
});
});
await session.startTurn("exercise compaction lifecycle");
appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" });
await new Promise<void>((resolve) => setImmediate(resolve));
return { appServer, session, events, terminalEvent };
}
function archivedThreadHandle() {
return {
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
};
}
function archivedThreadErrorMessage(threadId: string): string {
return (
`session ${threadId} is archived. ` +
`Run \`codex unarchive ${threadId}\` to unarchive it first.`
);
}
function codexUserItem(id: string, text: string) {
return { type: "userMessage", id, content: [{ type: "text", text }] };
}
function codexAgentItem(text: string) {
return { type: "agentMessage", id: `agent-${text}`, text };
}
// An AgentManager over a real Codex provider whose every spawn gets a fresh fake
// app-server serving `threads` from thread/read.
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
const appServers: FakeCodexAppServer[] = [];
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
}),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
// The fake app-server stands in for the Codex CLI, which test machines may not have.
const client: AgentClient = {
provider: "codex",
capabilities: provider.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: async () => {
throw new Error("Unexpected create");
},
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agentId = "00000000-0000-4000-8000-000000000001";
const handle = { provider: "codex", sessionId: "saved-thread" };
const config = createConfig({ cwd: process.cwd() });
async function open(): Promise<void> {
await manager.resumeAgentFromPersistence(handle, config, agentId);
await manager.hydrateTimelineFromProvider(agentId);
}
return {
manager,
agentId,
latestAppServer: () => appServers[appServers.length - 1],
threadMethods: (index: number) =>
appServers[index]
.requests()
.map((request) => request.method)
.filter((method) => typeof method === "string" && method.startsWith("thread/")),
// Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline.
async loadCloseAndReopen(): Promise<AgentTimelineItem[]> {
await open();
const projected = manager.getTimeline(agentId);
await manager.closeAgent(agentId);
await open();
return projected;
},
async close(): Promise<void> {
await manager.closeAgent(agentId);
for (const appServer of appServers) appServer.assertNoErrors();
},
};
}
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return castInternals<CodexSessionTestAccess>(session);
}
function markdownImageSource(markdown: string): string {
const match = markdown.trim().match(/^!\[[^\]]*]\((.*)\)$/);
if (!match) {
throw new Error(`Expected markdown image, got: ${markdown}`);
}
const source = match[1].replace(/\\\)/g, ")");
return source.startsWith("file://") ? fileURLToPath(source) : source;
}
function latestBackgroundWork(events: AgentStreamEvent[]) {
return events.findLast((event) => event.type === "background_work");
}
function hasBackgroundListRequest(appServer: FakeCodexAppServer, threadId: string) {
return appServer
.requests()
.some(
(request) =>
request.method === "thread/backgroundTerminals/list" &&
(request.params as { threadId: string }).threadId === threadId,
);
}
function emitCodexUserMessage(
appServer: FakeCodexAppServer,
input: { id: string; text: string; threadId?: string; turnId?: string },
): void {
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: input.threadId ?? "thread-1",
...(input.turnId ? { turnId: input.turnId } : {}),
item: {
type: "userMessage",
id: input.id,
content: [{ type: "text", text: input.text }],
},
},
})}\n`,
);
}
type CapturedFakeCodexRecord = Record<string, unknown>;
async function runCustomCodexProviderTurn(
providerId: string,
baseUrl: string,
): Promise<CapturedFakeCodexRecord[]> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({
kind: "env",
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
}) + "\\n");
function record(method, params) {
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
}
function resultFor(method) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "config/read") return { config: {} };
if (method === "getUserSavedConfig") return { config: {} };
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
if (method === "turn/start") return {};
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
record(message.method, message.params);
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
);
const registry = buildProviderRegistry(createTestLogger(), {
providerOverrides: {
[providerId]: {
extends: "codex",
label: "Custom Codex",
command: [process.execPath, fakeAppServerPath],
env: {
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: baseUrl,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry[providerId].createClient(createTestLogger()).createSession({
provider: providerId,
cwd: "/workspace/project",
modeId: "auto",
model: "custom-model",
});
try {
await session.startTurn("use the custom endpoint");
return readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
}
}
async function withCustomCodexProviderHome<T>(
run: (input: {
session: AgentSession;
readCaptured: () => CapturedFakeCodexRecord[];
}) => Promise<T>,
providerPrompts: Record<string, string> = {},
): Promise<T> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
const providerCodexHome = path.join(tempDir, "provider-codex-home");
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
mkdirSync(path.join(daemonCodexHome, "prompts"), { recursive: true });
mkdirSync(path.join(providerCodexHome, "prompts"), { recursive: true });
writeFileSync(
path.join(daemonCodexHome, "prompts", "probe-default.md"),
"---\ndescription: Daemon home prompt\n---\nfrom the daemon home\n",
);
writeFileSync(
path.join(providerCodexHome, "prompts", "probe-profile.md"),
"---\ndescription: Provider home prompt\n---\nfrom the provider home\n",
);
for (const [name, content] of Object.entries(providerPrompts)) {
writeFileSync(path.join(providerCodexHome, "prompts", `${name}.md`), content);
}
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({ kind: "env", CODEX_HOME: process.env.CODEX_HOME }) + "\\n");
function resultFor(method) {
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "model/list") return { data: [{ id: "profile-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method: message.method, params: message.params }) + "\\n");
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
);
vi.stubEnv("CODEX_HOME", daemonCodexHome);
const registry = buildProviderRegistry(createTestLogger(), {
providerOverrides: {
"profile-codex": {
extends: "codex",
label: "Profile Codex",
command: [process.execPath, fakeAppServerPath],
env: {
CODEX_HOME: providerCodexHome,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry["profile-codex"].createClient(createTestLogger()).createSession({
provider: "profile-codex",
cwd: tempDir,
modeId: "auto",
});
try {
expect(session.usageSession?.()).toMatchObject({
provider: "codex",
env: { CODEX_HOME: providerCodexHome },
});
expect(session.usageSession?.()?.sessionKey).toBe(session.usageSession?.()?.sessionKey);
return await run({
session,
readCaptured: () =>
readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord),
});
} finally {
await session.close();
expect(session.usageSession?.()).toBeNull();
vi.unstubAllEnvs();
rmSync(tempDir, { recursive: true, force: true });
}
}
async function listPromptCommandsFromCustomCodexHome(): Promise<string[]> {
return withCustomCodexProviderHome(async ({ session }) => {
const commands = await session.listCommands!();
return commands.map((command) => command.name).filter((name) => name.startsWith("prompts:"));
});
}
async function runPromptFromCustomCodexHome(
prompt: string,
providerPrompts: Record<string, string> = {},
): Promise<CapturedFakeCodexRecord[]> {
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
await session.startTurn(prompt);
return readCaptured();
}, providerPrompts);
}
async function expandCustomPrompt(template: string, args: string): Promise<string> {
const records = await runPromptFromCustomCodexHome(`/prompts:template ${args}`, { template });
const turnStart = records.find((record) => record.method === "turn/start");
const params = turnStart?.params as { input: Array<{ text: string }> };
return params.input[0].text;
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
const threadStart = records.find((record) => record.method === "thread/start");
const params = threadStart?.params as Record<string, unknown> | undefined;
return params?.config;
}
async function listCommandsFromFakeCodex(
skills: unknown[],
filesystemSkills: Array<{ name: string; description: string }> = [],
): Promise<AgentSlashCommand[]> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-command-list-"));
const projectCwd = path.join(tempDir, "project");
const fakeCodexPath = path.join(tempDir, "fake-codex.cjs");
mkdirSync(projectCwd, { recursive: true });
for (const skill of filesystemSkills) {
const skillDir = path.join(projectCwd, ".codex", "skills", skill.name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(
path.join(skillDir, "SKILL.md"),
`---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n`,
);
}
writeFileSync(
fakeCodexPath,
`
let buffer = "";
function resultFor(method, params) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "model/list") return { data: [] };
if (method === "skills/list") {
const cwds = params && params.cwds;
const projectCwd = ${JSON.stringify(projectCwd)};
if (!Array.isArray(cwds) || cwds.length !== 1 || cwds[0] !== projectCwd) {
return { data: [] };
}
return {
data: [
{
cwd: projectCwd,
skills: ${JSON.stringify(skills)},
errors: [],
},
],
};
}
throw new Error("Unexpected Codex request: " + method);
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
if (typeof message.id !== "number") continue;
try {
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method, message.params) }) + "\\n");
} catch (error) {
process.stdout.write(JSON.stringify({ id: message.id, error: { message: error.message } }) + "\\n");
}
}
});
`,
);
const client = new CodexAppServerAgentClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, fakeCodexPath] },
});
const session = await client.createSession(createConfig({ cwd: projectCwd }));
try {
return await session.listCommands();
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
}
}
describe("Codex app-server provider", () => {
test("getAvailableModes includes auto-review when the Codex version supports it", async () => {
const session = createSession({}, { autoReviewEnabled: true });
await expect(session.getAvailableModes()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "auto-review",
label: "Auto-review",
}),
]),
);
});
test("getAvailableModes excludes auto-review when the Codex version is too old", async () => {
const session = createSession({}, { autoReviewEnabled: false });
await expect(session.getAvailableModes()).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: "auto-review" })]),
);
});
test("setMode auto-review sends approvalsReviewer to thread/start", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession(
{ modeId: "auto", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "auto-review-thread" } };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.setMode("auto-review");
await session.startTurn("trigger thread creation");
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall?.params).toMatchObject({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
});
test("Default Permissions pins the user as approvals reviewer on thread/start", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession(
{ modeId: "auto", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "default-mode-thread" } };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.startTurn("trigger thread creation");
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall?.params).toMatchObject({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
});
test("switching from auto-review back to Default returns approvals to the user", async () => {
const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true });
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.setMode("auto");
await session.startTurn("needs approval");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
approvalsReviewer: "user",
}),
);
});
test("Read-only pins the user as approvals reviewer on thread/start", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession(
{ modeId: "read-only", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "read-only-thread" } };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.startTurn("trigger thread creation");
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall?.params).toMatchObject({
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "user",
});
});
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
await expect(session.setMode("full-access")).resolves.toEqual({
type: "warning",
message: "Permission mode applies next turn",
});
await expect(session.setThinkingOption?.("high")).resolves.toEqual({
type: "warning",
message: "Thinking level applies next turn",
});
session.activeForegroundTurnId = null;
await expect(session.setMode("auto")).resolves.toBeUndefined();
await expect(session.setThinkingOption?.("low")).resolves.toBeUndefined();
});
test.each(["auto_review", "guardian_subagent"])(
"parses %s thread/start response as auto-review mode",
async (approvalsReviewer) => {
const session = createSession(
{ modeId: "auto", thinkingOptionId: "medium" },
{ autoReviewEnabled: true },
);
session.currentThreadId = null;
session.activeForegroundTurnId = null;
session.client = {
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
return {
thread: { id: "auto-review-thread" },
approvalPolicy: "on-request",
sandbox: { type: "workspaceWrite", networkAccess: false },
approvalsReviewer,
};
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
}),
};
await session.startTurn("trigger thread creation");
await expect(session.getCurrentMode()).resolves.toBe("auto-review");
},
);
test("turn/start forwards approvalsReviewer while in auto-review mode", async () => {
const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true });
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("needs approval");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
approvalsReviewer: "auto_review",
}),
);
});
test("omitted mode preserves Codex resolved approval and sandbox config", async () => {
const session = createSession({ modeId: undefined });
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("inherit config");
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
expect(turnStart).not.toHaveProperty("approvalPolicy");
expect(turnStart).not.toHaveProperty("sandboxPolicy");
expect(turnStart).not.toHaveProperty("approvalsReviewer");
});
test("carries the complete native workspace-write policy including writable roots", async () => {
const session = createSession({
modeId: undefined,
providerOptions: {
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/var/cache/npm", "/tmp/build-cache"],
network_access: true,
exclude_slash_tmp: true,
exclude_tmpdir_env_var: true,
},
},
});
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("use writable roots");
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
expect(turnStart).toMatchObject({
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/var/cache/npm", "/tmp/build-cache"],
networkAccess: true,
excludeSlashTmp: true,
excludeTmpdirEnvVar: true,
},
config: {
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/var/cache/npm", "/tmp/build-cache"],
},
},
});
});
test("preserves cwd-resolved Codex writable roots under an explicit workflow mode", async () => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
config: {
sandbox_workspace_write: {
writable_roots: ["/var/cache/npm"],
network_access: true,
exclude_slash_tmp: true,
exclude_tmpdir_env_var: true,
},
},
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ modeId: "auto" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
await session.startTurn("keep native roots");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/var/cache/npm"],
networkAccess: true,
excludeSlashTmp: true,
excludeTmpdirEnvVar: true,
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps the default effort when a session without one is resumed after a restart", async () => {
const beforeRestart = createFakeCodexAppServer();
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
createConfig({ thinkingOptionId: undefined }),
);
await firstSession.startTurn("first turn");
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
const saved = waitForNextEvent(firstSession, "thread_started");
beforeRestart.startsTurn({ threadId: "thread-1" });
await saved;
const handle = firstSession.describePersistence()!;
await firstSession.close();
const afterRestart = createFakeCodexAppServer();
// The daemon resumes with the stored agent config, which never recorded an effort.
const resumed = await createProviderWithFakeAppServer(afterRestart).resumeSession(handle, {
model: "gpt-5.4",
thinkingOptionId: undefined,
});
try {
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
thinkingOptionId: "medium",
});
await resumed.startTurn("turn after restart");
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
beforeRestart.assertNoErrors();
afterRestart.assertNoErrors();
} finally {
await resumed.close();
}
});
test.each([undefined, "configured-model"])(
"import resolves model %s before the first collaboration turn",
async (configuredModel) => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
config: { model: configuredModel, model_reasoning_effort: "low" },
}),
"collaborationMode/list": () => ({
data: [{ name: "Code", mode: "default", model: null, reasoning_effort: null }],
}),
});
const provider = createProviderWithFakeAppServer(appServer);
const config = createConfig({ model: undefined, thinkingOptionId: undefined });
const imported = await provider.importSession(
{ providerHandleId: "native-thread", cwd: config.cwd! },
{ config, storedConfig: config },
);
try {
await imported.session.startTurn("first follow-up after import");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
threadId: "native-thread",
model: configuredModel ?? "gpt-5.4",
effort: "low",
collaborationMode: {
mode: "default",
settings: { model: configuredModel ?? "gpt-5.4", reasoning_effort: "low" },
},
});
appServer.assertNoErrors();
} finally {
await imported.session.close();
}
},
);
test("provider persistence leaves options owned by the stored agent config", async () => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.createSession(
createConfig({ providerOptions: { sandbox_mode: "read-only", approval_policy: "never" } }),
);
try {
await session.startTurn("persist the configured session");
await appServer.waitForTurnStart();
const saved = waitForNextEvent(session, "thread_started");
appServer.startsTurn({ threadId: "thread-1" });
await saved;
const handle = session.describePersistence()!;
expect(handle.metadata).not.toHaveProperty("providerOptions");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("reports a new thread only after Codex announces its first turn", async () => {
const appServer = createFakeCodexAppServer();
const session = await createProviderWithFakeAppServer(appServer).createSession(createConfig());
try {
// Codex has started the thread but writes its rollout at the first turn, so another
// app-server could not resume it yet.
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: null });
expect(session.describePersistence()).toBeNull();
expect(
appServer.requests().filter((request) => request.method === "thread/start"),
).toHaveLength(1);
await session.startTurn("first prompt");
await appServer.waitForTurnStart();
expect(session.describePersistence()).toBeNull();
const saved = waitForNextEvent(session, "thread_started");
appServer.startsTurn({ threadId: "thread-1" });
await expect(saved).resolves.toMatchObject({
type: "thread_started",
provider: "codex",
sessionId: "thread-1",
});
expect(session.describePersistence()).toMatchObject({
sessionId: "thread-1",
nativeHandle: "thread-1",
});
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "thread-1" });
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("reports a resumed thread before its next turn", async () => {
const appServer = createFakeCodexAppServer();
const session = await createProviderWithFakeAppServer(appServer).resumeSession(
{ provider: "codex", sessionId: "saved-thread" },
createConfig(),
);
try {
expect(session.describePersistence()).toMatchObject({ sessionId: "saved-thread" });
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "saved-thread" });
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preapproves only granted tools on the injected Codex MCP server", async () => {
const session = createSession({
modeId: undefined,
providerOptions: { sandbox_mode: "read-only" },
mcpServers: {
hub: { type: "http", url: "http://127.0.0.1/hub" },
},
toolPolicy: {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
},
});
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("finish");
const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1];
expect(turnStart).toMatchObject({
sandboxPolicy: { type: "readOnly" },
config: {
sandbox_mode: "read-only",
mcp_servers: {
hub: {
enabled_tools: ["finish_execution"],
default_tools_approval_mode: "prompt",
tools: { finish_execution: { approval_mode: "approve" } },
},
},
},
});
expect(turnStart).not.toHaveProperty("config.mcp_servers.hub.tools.reply");
});
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const fakeClient: CodexClientLike = {
async request(method: string, params?: unknown) {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "ephemeral-thread" } };
}
return null;
},
};
const session = new CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
{},
true,
);
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
expect(startCall?.params).toMatchObject({ ephemeral: true });
});
test("omits ephemeral from thread/start by default", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const fakeClient: CodexClientLike = {
async request(method: string, params?: unknown) {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "persistent-thread" } };
}
return null;
},
};
const session = new CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
);
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
expect((startCall!.params as Record<string, unknown>).ephemeral).toBeUndefined();
});
test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn((signal) => {
if (signal === "SIGKILL") {
child.signalCode = "SIGKILL";
child.emit("exit", null, "SIGKILL");
}
return true;
}) as ChildProcessWithoutNullStreams["kill"];
const client = new CodexAppServerClient(child, createTestLogger());
try {
const disposePromise = client.dispose();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(2_000);
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
await vi.advanceTimersByTimeAsync(1_000);
await expect(disposePromise).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.connect();
appServer.assertNoErrors();
const permissionRequested = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
});
const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({
id: "permission-exec-approval-1",
provider: "codex",
name: "CodexBash",
kind: "tool",
title: "Run command: git restore README.md",
description: "requires escalated permissions",
input: {
command: "git restore README.md",
cwd: "/workspace/project",
},
metadata: {
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
},
});
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
decision: "accept",
});
appServer.assertNoErrors();
await session.close();
});
test("answers each concurrent command approval callback that shares one item", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const requested: AgentPermissionRequest[] = [];
session.subscribe((event) => {
if (event.type === "permission_requested") requested.push(event.request);
});
try {
await session.connect();
const firstPermission = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "shared-item",
approvalId: "callback-a",
threadId: "thread-1",
turnId: "turn-1",
command: "git add a.txt",
cwd: "/workspace/project",
reason: "subcommand a",
});
await firstPermission;
const secondPermission = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "shared-item",
approvalId: "callback-b",
threadId: "thread-1",
turnId: "turn-1",
command: "git add b.txt",
cwd: "/workspace/project",
reason: "subcommand b",
});
await secondPermission;
expect(requested.map((request) => request.input?.command)).toEqual([
"git add a.txt",
"git add b.txt",
]);
expect(new Set(requested.map((request) => request.id)).size).toBe(2);
expect(session.getPendingPermissions()).toHaveLength(2);
await session.respondToPermission(requested[0]!.id, { behavior: "allow" });
await session.respondToPermission(requested[1]!.id, {
behavior: "deny",
message: "not b",
});
await expect(appServer.waitForCommandApprovalDecision("callback-a")).resolves.toEqual({
decision: "accept",
});
await expect(appServer.waitForCommandApprovalDecision("callback-b")).resolves.toEqual({
decision: "decline",
});
expect(session.getPendingPermissions()).toHaveLength(0);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows a successful shell command that produces no output", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentCommand({
threadId: "thread-1",
callId: "silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({ threadId: "thread-1", text: "Merged." });
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows a silent shell command from legacy live notifications", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentLegacyCommand({
threadId: "thread-1",
callId: "legacy-silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "legacy-silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows the exact bytes Codex writes into an existing terminal", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "gh pr merge 2030 --squash\n",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
const relabeledTerminal = waitForTimelineToolCall(session, "terminal-session-4242-1");
appServer.runsLegacyCommand({
threadId: "thread-1",
callId: "interactive-shell",
command: "sleep 30",
output: "Process running with session id 4242",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(relabeledTerminal).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "sleep 30",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each(["python3 -i", "/usr/bin/zsh -lc 'python3 -i'"])(
"labels a terminal write started as %s with the command that started the terminal",
async (command) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
appServer.startsTerminalCommand({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "73",
command,
});
await new Promise<void>((resolve) => setImmediate(resolve));
const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1");
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "73",
text: "print(1)\n",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(terminalWrite).resolves.toMatchObject({
item: {
callId: "terminal-session-73-1",
name: "terminal",
detail: { type: "plain_text", label: "python3 -i", text: "print(1)\n" },
metadata: { processId: "73" },
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("keeps repeated writes to one terminal as separate timeline rows", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const firstTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git status\n",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await firstTimelineItem;
const secondTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git push\n",
});
await new Promise<void>((resolve) => setImmediate(resolve));
const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]);
expect(first.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-1",
detail: { type: "plain_text", text: "git status\n" },
});
expect(second.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-2",
detail: { type: "plain_text", text: "git push\n" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("surfaces an MCP elicitation and returns Codex's required approval action", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.connect();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const permissionRequested = waitForNextPermission(session);
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "turn-1",
serverName: "browser",
message: "Allow the browser to open this page?",
requestedSchema: {
type: "object",
properties: {},
},
});
const permission = await permissionRequested;
expect(permission.request).toEqual({
id: expect.any(String),
provider: "codex",
name: "CodexMcpElicitation",
kind: "tool",
title: "MCP approval: browser",
description: "Allow the browser to open this page?",
input: {
mode: "openai/form",
requestedSchema: {
type: "object",
properties: {},
},
url: null,
},
metadata: {
threadId: "thread-1",
turnId: "turn-1",
serverName: "browser",
elicitationId: null,
},
});
await session.respondToPermission(permission.request.id, { behavior: "allow" });
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "accept",
content: {},
_meta: null,
});
appServer.resolvesMcpElicitation();
await vi.waitFor(() => {
expect(events).toContainEqual({
type: "permission_resolved",
provider: "codex",
requestId: permission.request.id,
resolution: { behavior: "allow" },
});
});
expect(events).not.toContainEqual({
type: "permission_resolved",
provider: "codex",
requestId: permission.request.id,
resolution: { behavior: "deny", interrupt: true },
});
await session.close();
});
test("initializes Codex app-server without making Paseo the request originator", async () => {
let initializeParams: unknown;
const appServer = createFakeCodexAppServer({
initialize: (params) => {
initializeParams = params;
return {};
},
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.connect();
expect(initializeParams).toEqual({
clientInfo: {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
},
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
});
appServer.assertNoErrors();
await session.close();
});
test("resumed session exposes usage before connecting and not after close", async () => {
let spawns = 0;
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => {
spawns++;
throw new Error("unexpected spawn");
},
{ environment: { HOME: "/fixture/codex", CODEX_HOME: "/fixture/profile" } },
);
expect(session.usageSession()).toMatchObject({
provider: "codex",
env: { CODEX_HOME: "/fixture/profile" },
sessionKey: expect.any(String),
});
expect(spawns).toBe(0);
await session.close();
expect(session.usageSession()).toBeNull();
});
test("loads archived Codex history without resuming the native thread", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id")));
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.resumeSession(archivedThreadHandle(), undefined, undefined, {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
await session.close();
appServer.assertNoErrors();
});
test("unarchives Codex when an active Paseo agent resumes an archived thread", async () => {
const threadRequests: string[] = [];
let resumeAttempts = 0;
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
resumeAttempts += 1;
if (resumeAttempts === 1) {
return Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id")));
}
return { thread: { id: "archived-thread-id" } };
},
"thread/unarchive": () => {
threadRequests.push("thread/unarchive");
return { thread: { id: "archived-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.resumeSession(archivedThreadHandle());
expect(threadRequests).toEqual([
"thread/loaded/list",
"thread/resume",
"thread/unarchive",
"thread/resume",
]);
await session.close();
appServer.assertNoErrors();
});
test("resumes a Codex thread without asking for its turns", async () => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.resumeSession({ sessionId: "saved-thread" });
expect(
appServer
.requests()
.filter((request) => request.method === "thread/resume")
.map((request) => request.params),
).toEqual([expect.objectContaining({ threadId: "saved-thread", excludeTurns: true })]);
await session.close();
appServer.assertNoErrors();
});
test("reopening an agent whose timeline Paseo still holds does not read Codex history", async () => {
const codex = createReopenableCodexAgent({
"saved-thread": [
{ id: "turn-1", items: [codexUserItem("user-1", "Hello"), codexAgentItem("Hi there.")] },
],
});
try {
const projected = await codex.loadCloseAndReopen();
expect(projected).toEqual([
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }),
expect.objectContaining({ type: "assistant_message", text: "Hi there." }),
]);
// Background terminal observation is independent of conversation history loading.
const historyMethods = (index: number) =>
codex.threadMethods(index).filter((method) => method !== "thread/backgroundTerminals/list");
expect(historyMethods(0)).toEqual([
"thread/loaded/list",
"thread/resume",
"thread/read",
"thread/read",
]);
expect(historyMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
} finally {
await codex.close();
}
});
test("rewinds an agent reopened without reading its Codex history", async () => {
const firstTurn = {
id: "turn-1",
items: [codexUserItem("user-1", "First"), codexAgentItem("First answer.")],
};
const codex = createReopenableCodexAgent({
"saved-thread": [
firstTurn,
{ id: "turn-2", items: [codexUserItem("user-2", "Second"), codexAgentItem("Second.")] },
],
"forked-thread": [firstTurn],
});
try {
await codex.loadCloseAndReopen();
await codex.manager.rewind(codex.agentId, "user-2", "conversation");
expect(codex.latestAppServer().recordedRollbacks).toEqual([
{ threadId: "forked-thread", numTurns: 1 },
]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual([
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }),
expect.objectContaining({ type: "assistant_message", text: "First answer." }),
]);
} finally {
await codex.close();
}
});
test("keeps a reopened agent's interaction with an earlier sub-agent on its original card", async () => {
const codex = createReopenableCodexAgent({
"saved-thread": [
{
id: "turn-1",
items: [
codexUserItem("user-1", "Delegate"),
{
type: "subAgentActivity",
id: "spawn-child",
kind: "started",
agentThreadId: "child-thread",
agentPath: "/root/child",
},
],
},
],
"child-thread": [{ id: "child-turn-1", items: [codexAgentItem("Child started.")] }],
});
try {
await codex.loadCloseAndReopen();
const appServer = codex.latestAppServer();
const run = codex.manager.runAgent(codex.agentId, "Ask the child again");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "saved-thread", turnId: "turn-2" });
appServer.beginsSubAgentActivity({
callId: "interact-child",
threadId: "child-thread",
parentThreadId: "saved-thread",
kind: "interacted",
agentPath: "/root/child",
});
appServer.says({ threadId: "child-thread", itemId: "child-reply", text: "Child replied." });
appServer.completesSubAgentActivity({
callId: "interact-child",
threadId: "child-thread",
parentThreadId: "saved-thread",
kind: "interacted",
agentPath: "/root/child",
});
appServer.completeTurn({ threadId: "saved-thread" });
await run;
const toolCalls = codex.manager
.getTimeline(codex.agentId)
.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" });
expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
toolCallId: "spawn-child",
status: "completed",
});
expect(
codex.manager
.fetchProviderSubagentTimeline(codex.agentId, "child-thread")
.rows.map((row) => row.item),
).toEqual([
expect.objectContaining({ type: "assistant_message", text: "Child started." }),
expect.objectContaining({ type: "assistant_message", text: "Child replied." }),
]);
} finally {
await codex.close();
}
});
test("closes Codex app-server when an interactive resume fails", async () => {
const appServer = createFakeCodexAppServer({
"thread/resume": () => Promise.reject(new Error("thread resume failed")),
});
const killSpy = vi.spyOn(appServer.child, "kill");
const provider = createProviderWithFakeAppServer(appServer);
await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow(
"thread resume failed",
);
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
appServer.assertNoErrors();
});
test("closes Codex app-server when archived history hydration fails", async () => {
const appServer = createFakeCodexAppServer({
"thread/resume": () =>
Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))),
"thread/read": () => Promise.reject(new Error("thread history is unavailable")),
});
const killSpy = vi.spyOn(appServer.child, "kill");
const provider = createProviderWithFakeAppServer(appServer);
await expect(
provider.resumeSession(archivedThreadHandle(), undefined, undefined, { purpose: "history" }),
).rejects.toThrow("thread history is unavailable");
expect(killSpy).toHaveBeenCalledWith("SIGTERM");
appServer.assertNoErrors();
});
test("unarchives a persisted Codex thread through app-server", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "persisted-thread-id",
nativeHandle: "native-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "native-thread-id" } },
]);
appServer.assertNoErrors();
});
test("archives the persisted native thread without opening an interactive session", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/archive": (params) => {
threadRequests.push({ method: "thread/archive", params });
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.archiveNativeSession({
provider: "codex",
sessionId: "persisted-thread-id",
nativeHandle: "native-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/archive", params: { threadId: "native-thread-id" } },
]);
appServer.assertNoErrors();
});
test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return { thread: { id: "persisted-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "persisted-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "persisted-thread-id" } },
]);
appServer.assertNoErrors();
});
test("treats a readable Codex thread as already unarchived", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return Promise.reject(
new Error(
"failed to unarchive thread: no archived rollout found for thread id active-thread-id",
),
);
},
"thread/read": (params) => {
threadRequests.push({ method: "thread/read", params });
return { thread: { id: "active-thread-id", turns: [] } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "active-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "active-thread-id" } },
{ method: "thread/read", params: { threadId: "active-thread-id" } },
]);
appServer.assertNoErrors();
});
test("propagates Codex unarchive failure when the thread cannot be read", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return Promise.reject(
new Error(
"failed to unarchive thread: no archived rollout found for thread id missing-thread-id",
),
);
},
"thread/read": (params) => {
threadRequests.push({ method: "thread/read", params });
return Promise.reject(new Error("thread not found"));
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await expect(
provider.unarchiveNativeSession({
provider: "codex",
sessionId: "missing-thread-id",
}),
).rejects.toThrow("no archived rollout found for thread id missing-thread-id");
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "missing-thread-id" } },
{ method: "thread/read", params: { threadId: "missing-thread-id" } },
]);
appServer.assertNoErrors();
});
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember first");
emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" });
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("remember second");
emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" });
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.revertConversation({ messageId: "codex-first" });
expect(appServer.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
sessionId: "forked-thread",
});
appServer.assertNoErrors();
await session.close();
});
test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }),
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "legacy", turns: [] },
}),
"thread/rollback": () => ({
__jsonRpcError: {
code: -32600,
message: "Invalid request: unknown variant `thread/rollback`",
},
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember first");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "remember first",
turnId: "turn-first",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("remember second");
emitCodexUserMessage(appServer, {
id: "codex-second",
text: "remember second",
turnId: "turn-second",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.revertConversation({ messageId: "codex-second" });
const forkRequests = appServer
.requests()
.filter((request) => request.method === "thread/fork")
.map((request) => request.params);
expect(forkRequests).toEqual([
{
threadId: "thread-1",
beforeTurnId: "turn-second",
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
expect(appServer.recordedRollbacks).toEqual([]);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
sessionId: "forked-thread",
});
appServer.assertNoErrors();
await session.close();
});
test("keeps a background terminal controllable on its original thread after rewind", async () => {
let live = true;
const appServer = createFakeCodexAppServer({
initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }),
"thread/read": () => ({ thread: { id: "thread-1", historyMode: "legacy", turns: [] } }),
"thread/backgroundTerminals/list": (params) => {
const threadId = (params as { threadId: string }).threadId;
if (threadId === "thread-1" && live)
return { data: [{ processId: "12", command: "sleep 180" }], nextCursor: null };
if (threadId === "forked-thread")
return { data: [{ processId: "12", command: "sleep 99" }], nextCursor: null };
return { data: [], nextCursor: null };
},
"thread/backgroundTerminals/terminate": (params) => {
expect(params).toMatchObject({ threadId: "thread-1", processId: "12" });
live = false;
return { terminated: true };
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("first");
emitCodexUserMessage(appServer, { id: "first", text: "first", turnId: "turn-first" });
appServer.completeTurn();
await vi.waitFor(() =>
expect(latestBackgroundWork(events)).toMatchObject({
items: [{ command: "sleep 180" }],
}),
);
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("second");
emitCodexUserMessage(appServer, { id: "second", text: "second", turnId: "turn-second" });
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.revertConversation({ messageId: "second" });
await session.startTurn("continue");
appServer.startsTerminalCommand({
threadId: "forked-thread",
turnId: "turn-next",
itemId: "new",
processId: "12",
command: "echo hello",
});
await vi.waitFor(() =>
expect(hasBackgroundListRequest(appServer, "forked-thread")).toBe(true),
);
await vi.waitFor(() =>
expect(latestBackgroundWork(events)).toMatchObject({
items: [{ command: "sleep 180" }, { command: "sleep 99" }],
}),
);
const items = latestBackgroundWork(events)?.items;
expect(items).toHaveLength(2);
expect(items![0].id).not.toBe(items![1].id);
await session.stopBackgroundWork(items![0].id);
expect(latestBackgroundWork(events)).toMatchObject({
items: [{ command: "sleep 99" }],
});
} finally {
await session.close();
}
});
test("rewinds a paginated conversation through the public session capability", async () => {
const appServer = createFakeCodexAppServer({
...fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
}),
"thread/rollback": () => {
throw new Error("paginated threads do not support thread/rollback");
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember first");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "remember first",
turnId: "turn-first",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("remember second");
emitCodexUserMessage(appServer, {
id: "codex-second",
text: "remember second",
turnId: "turn-second",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.revertConversation({ messageId: "codex-first" });
const forkRequests = appServer
.requests()
.filter((request) => request.method === "thread/fork")
.map((request) => request.params);
expect(forkRequests).toEqual([
{
threadId: "thread-1",
beforeTurnId: "turn-first",
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
expect(appServer.recordedRollbacks).toEqual([]);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
sessionId: "forked-thread",
});
appServer.assertNoErrors();
await session.close();
});
test.each(["legacy", "paginated"] as const)(
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
async (historyMode) => {
const appServer = createFakeCodexAppServer(
historyMode === "paginated"
? fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
})
: undefined,
);
const customCodexConfig = {
model_provider: "codex-custom",
model_providers: {
"codex-custom": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
};
const session = new CodexAppServerAgentSession(
createConfig({
cwd: "/workspace/project",
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" },
},
}),
null,
createTestLogger(),
async () => appServer.child,
{ customCodexConfig },
);
await session.startTurn("remember first");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "remember first",
turnId: "turn-first",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("remember second");
emitCodexUserMessage(appServer, {
id: "codex-second",
text: "remember second",
turnId: "turn-second",
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.revertConversation({ messageId: "codex-first" });
const forkConfigs = appServer
.requests()
.filter((request) => request.method === "thread/fork")
.map((request) => (request.params as { config?: unknown }).config);
expect(forkConfigs).toEqual([
expect.objectContaining({
...customCodexConfig,
mcp_servers: {
paseo: expect.objectContaining({
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
}),
},
}),
]);
appServer.assertNoErrors();
await session.close();
},
);
test("correlates a Codex user message with the submitting client message", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("remember this", { clientMessageId: "client-message" });
await asInternals(session).handleNotification("turn/started", {
threadId: "thread-1",
turn: { id: "turn-1" },
});
const userMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, { id: "codex-message", text: "remember this" });
await expect(userMessage).resolves.toMatchObject({
item: {
type: "user_message",
messageId: "codex-message",
clientMessageId: "client-message",
},
});
// The first turn of a new thread also reports the thread, now that Codex has saved it.
expect(events.slice(0, 3).map((event) => event.type)).toEqual([
"thread_started",
"turn_started",
"timeline",
]);
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
"https://custom-relay.example.com",
);
expect(capturedRequests[0]).toEqual({
kind: "env",
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: "https://custom-relay.example.com",
});
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "paseo:codex-iisb",
model_providers: {
"paseo:codex-iisb": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
});
});
test("does not append v1 twice for custom Codex provider base URLs", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-custom",
"https://custom-relay.example.com/v1/",
);
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "paseo:codex-custom",
model_providers: {
"paseo:codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1",
}),
},
});
});
test("namespaces provider ids that collide with Codex built-ins", async () => {
// `ollama` is a reserved Codex provider id: without namespacing, Codex rejects
// the generated config outright and the agent never starts.
const capturedRequests = await runCustomCodexProviderTurn("ollama", "https://ollama.com");
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "paseo:ollama",
model_providers: {
"paseo:ollama": expect.objectContaining({
base_url: "https://ollama.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no tool-call found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no tool-call found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual([
"model/list",
"config/read",
"thread/loaded/list",
"thread/resume",
]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
const repoSkillDir = path.join(tempDir, "repo", ".codex", "skills", "shipper");
mkdirSync(cwd, { recursive: true });
mkdirSync(repoSkillDir, { recursive: true });
writeFileSync(
path.join(repoSkillDir, "SKILL.md"),
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
);
const workspaceGitService = {
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
};
try {
await expect(
listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService),
).resolves.toContainEqual({
name: "shipper",
description: "Ship changes carefully.",
argumentHint: "",
kind: "skill",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
const logger = createTestLogger();
test("extracts context window usage from snake_case token payloads", () => {
expect(
toAgentUsage({
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("extracts context window usage from camelCase token payloads", () => {
expect(
toAgentUsage({
modelContextWindow: 200000,
last: {
totalTokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("keeps existing usage behavior when context window fields are missing", () => {
expect(
toAgentUsage({
last: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("excludes invalid context window values", () => {
expect(
toAgentUsage({
model_context_window: Number.NaN,
modelContextWindow: "200000",
last: {
total_tokens: Number.NaN,
totalTokens: "50000",
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("normalizes raw output schemas for Codex structured outputs", () => {
const input = {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
};
const normalized = normalizeCodexOutputSchema(input);
expect(normalized).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity", "summary"],
additionalProperties: false,
},
},
overall: { type: "string" },
},
required: ["overall", "findings"],
additionalProperties: false,
});
expect(input).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
});
});
test("keeps an output field named properties as a field", () => {
const input = {
type: "object",
properties: { properties: { type: "string" } },
required: ["properties"],
additionalProperties: false,
};
expect(normalizeCodexOutputSchema(input)).toEqual(input);
});
test("normalizes schemas under keywords and leaves literal values alone", () => {
const literal = { type: "object", properties: { type: "string" } };
const input = {
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] },
},
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
},
},
};
expect(normalizeCodexOutputSchema(input)).toEqual({
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: {
anyOf: [
{
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
],
},
},
required: ["result", "choice"],
additionalProperties: false,
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
required: ["value"],
additionalProperties: false,
},
},
});
});
test("passes a normalized output schema to turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Return JSON", {
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
},
});
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
required: ["summary"],
additionalProperties: false,
},
}),
);
});
test("resolves Codex skill slash commands into app-server skill input", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "skills/list") {
return {
data: [
{
cwd: "/tmp/codex-question-test",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
};
}
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "$paseo-implement in a worktree, remember to use Claude for the UI",
text_elements: [],
},
],
}),
);
});
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
const commands = await listCommandsFromFakeCodex([
{
name: "project-skill-discovery-regression",
description: "A skill discovered from this project.",
path: "/tmp/codex-question-test/.agents/skills/project-skill-discovery-regression/SKILL.md",
},
]);
expect(commands).toContainEqual({
name: "project-skill-discovery-regression",
description: "A skill discovered from this project.",
argumentHint: "",
kind: "skill",
});
});
test("lists custom prompts from the CODEX_HOME a custom provider runs Codex with", async () => {
await expect(listPromptCommandsFromCustomCodexHome()).resolves.toEqual([
"prompts:probe-profile",
]);
});
test("runs a custom prompt from the CODEX_HOME a custom provider runs Codex with", async () => {
const records = await runPromptFromCustomCodexHome("/prompts:probe-profile");
expect(records.find((record) => record.kind === "env")?.CODEX_HOME).toMatch(
/provider-codex-home$/,
);
const turnStart = records.find((record) => record.method === "turn/start");
expect(JSON.stringify(turnStart?.params)).toContain("from the provider home");
});
test("inserts custom prompt arguments literally", async () => {
await expect(
expandCustomPrompt("Implement this exact request: $ARGUMENTS", "Preserve $1 unchanged"),
).resolves.toBe("Implement this exact request: Preserve $1 unchanged");
await expect(
expandCustomPrompt("Treat this as literal replacement text: $VALUE.", 'VALUE="$&"'),
).resolves.toBe("Treat this as literal replacement text: $&.");
await expect(expandCustomPrompt("$1 then $2", '"$2" two')).resolves.toBe("$2 then two");
await expect(expandCustomPrompt("$A and $B", 'A="$B" B=b')).resolves.toBe("$B and b");
await expect(
expandCustomPrompt("$ARGUMENTS", "keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"),
).resolves.toBe("keep __CODEX_DOLLAR_PLACEHOLDER__ as typed");
});
test("expands custom prompt placeholders and escapes", async () => {
await expect(
expandCustomPrompt("$1/$2 $NAME costs $$5, all: $ARGUMENTS, $MISSING", "one NAME=n two"),
).resolves.toBe("one/two n costs $5, all: one NAME=n two, $MISSING");
await expect(
expandCustomPrompt("$$ARGUMENTS $$1 $$NAME $9|$NAME_SUFFIX", "first NAME=value"),
).resolves.toBe("$ARGUMENTS $1 $NAME |$NAME_SUFFIX");
});
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
const commands = await listCommandsFromFakeCodex([
{
name: "paseo",
description: "Shared orchestration skill.",
path: "/Users/test/.agents/skills/paseo/SKILL.md",
},
{
name: "paseo",
description: "Shared orchestration skill.",
path: "/Users/test/.codex/skills/paseo/SKILL.md",
},
]);
expect(commands.filter((command) => command.name === "paseo")).toEqual([
{
name: "paseo",
description: "Shared orchestration skill.",
argumentHint: "",
kind: "skill",
},
]);
});
test("omits disabled Codex skills from slash commands", async () => {
const commands = await listCommandsFromFakeCodex([
{
name: "enabled-skill",
description: "An enabled skill.",
path: "/tmp/skills/enabled-skill/SKILL.md",
enabled: true,
},
{
name: "disabled-skill",
description: "A disabled skill.",
path: "/tmp/skills/disabled-skill/SKILL.md",
enabled: false,
},
{
name: "legacy-skill",
description: "Skill without enabled field (older Codex).",
path: "/tmp/skills/legacy-skill/SKILL.md",
},
]);
const skillCommands = commands.filter((command) => command.kind === "skill");
expect(skillCommands.map((command) => command.name).sort()).toEqual([
"enabled-skill",
"legacy-skill",
]);
expect(skillCommands.find((command) => command.name === "disabled-skill")).toBeUndefined();
});
test("does not rediscover disabled Codex skills through filesystem fallback", async () => {
const commands = await listCommandsFromFakeCodex(
[
{
name: "disabled-skill",
description: "A disabled skill.",
path: "/tmp/skills/disabled-skill/SKILL.md",
enabled: false,
},
],
[{ name: "disabled-skill", description: "A disabled skill." }],
);
expect(commands).not.toContainEqual(
expect.objectContaining({ name: "disabled-skill", kind: "skill" }),
);
});
test("maps image prompt blocks to Codex localImage input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{ type: "text", text: "hello" },
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
],
logger,
);
const localImage = input.find((item) => (item as { type?: string })?.type === "localImage") as
| { type: "localImage"; path?: string }
| undefined;
expect(localImage?.path).toBeTypeOf("string");
if (localImage?.path) {
expect(existsSync(localImage.path)).toBe(true);
rmSync(localImage.path, { force: true });
}
});
test("maps github_pr prompt attachments to Codex text input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
body: "Review body",
baseRefName: "main",
headRefName: "fix/worktree-race",
},
],
logger,
);
expect(input).toEqual([
{
type: "text",
text_elements: [],
text: expect.stringContaining("GitHub PR #123: Fix race in worktree setup"),
},
]);
});
test("passes Codex skill prompt blocks through to Codex app-server input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{ type: "skill", name: "fix-build", path: "/tmp/skills/fix-build/SKILL.md" },
{ type: "text", text: "keep this build moving" },
],
logger,
);
expect(input).toEqual([
{ type: "skill", name: "fix-build", path: "/tmp/skills/fix-build/SKILL.md" },
{ type: "text", text: "keep this build moving", text_elements: [] },
]);
});
test("separates Codex text prompts from rendered attachment text", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{ type: "text", text: "Please review this" },
{
type: "github_issue",
mimeType: "application/github-issue",
number: 456,
title: "Attachment spacing",
url: "https://github.com/getpaseo/paseo/issues/456",
},
],
logger,
);
expect(input).toEqual([
{ type: "text", text: "Please review this", text_elements: [] },
{
type: "text",
text: expect.stringMatching(/^\n\nGitHub Issue #456: Attachment spacing/),
text_elements: [],
},
]);
});
test("does not prefix Codex attachment-only prompts with a blank line", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{
type: "github_issue",
mimeType: "application/github-issue",
number: 456,
title: "Attachment spacing",
url: "https://github.com/getpaseo/paseo/issues/456",
},
],
logger,
);
expect(input).toEqual([
{
type: "text",
text: expect.stringMatching(/^GitHub Issue #456: Attachment spacing/),
text_elements: [],
},
]);
});
test("maps patch notifications with array-style changes and alias diff keys", () => {
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-array-alias",
changes: [
{
path: "/tmp/repo/src/array-alias.ts",
kind: "modify",
unified_diff: "@@\n-old\n+new\n",
},
],
cwd: "/tmp/repo",
running: false,
});
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/array-alias.ts");
expect(item.detail.unifiedDiff).toContain("-old");
expect(item.detail.unifiedDiff).toContain("+new");
expect(item.detail.newString).toBeUndefined();
}
});
test.each([
["patch_apply_begin", undefined, "running", null],
["patch_apply_end", true, "completed", null],
["patch_apply_end", false, "failed", { message: "Cannot apply patch" }],
] as const)(
"maps both files from legacy %s notifications (success: %s)",
async (type, success, status, error) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/tmp/repo" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.child.stdout.write(
`${JSON.stringify({
method: `codex/event/${type}`,
params: {
conversationId: "thread-1",
msg: {
type,
call_id: "patch-array-alias",
changes: [
{
path: "/tmp/repo/src/array-alias.ts",
kind: "modify",
unified_diff: "@@\n-old\n+new\n",
},
{
path: "/tmp/repo/src/second.ts",
kind: "modify",
unified_diff: "@@\n-before\n+after\n",
},
],
success,
stderr: "Cannot apply patch",
},
},
})}\n`,
);
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "patch-array-alias",
name: "apply_patch",
status,
error,
detail: {
type: "edit",
filePath: "src/array-alias.ts",
unifiedDiff: [
"diff --git a/src/array-alias.ts b/src/array-alias.ts",
"--- a/src/array-alias.ts",
"+++ b/src/array-alias.ts",
"@@",
"-old",
"+new",
"",
"diff --git a/src/second.ts b/src/second.ts",
"--- a/src/second.ts",
"+++ b/src/second.ts",
"@@",
"-before",
"+after",
].join("\n"),
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});
expect(item).toEqual({
type: "tool_call",
callId: "plan-turn-1",
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout\n- Add validation",
},
});
});
test("maps patch notifications with object-style single change payloads", () => {
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-object-single",
changes: {
path: "/tmp/repo/src/object-single.ts",
kind: "modify",
patch: "@@\n-before\n+after\n",
},
cwd: "/tmp/repo",
running: false,
});
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/object-single.ts");
expect(item.detail.unifiedDiff).toContain("-before");
expect(item.detail.unifiedDiff).toContain("+after");
expect(item.detail.newString).toBeUndefined();
}
});
test("maps patch notifications with file_path aliases in array-style changes", () => {
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-array-file-path",
changes: [
{
file_path: "/tmp/repo/src/alias-path.ts",
type: "modify",
diff: "@@\n-before\n+after\n",
},
],
cwd: "/tmp/repo",
running: false,
});
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/alias-path.ts");
expect(item.detail.unifiedDiff).toContain("-before");
expect(item.detail.unifiedDiff).toContain("+after");
expect(item.detail.newString).toBeUndefined();
}
});
test("builds app-server env from launch-context env overrides", () => {
const launchContext: AgentLaunchContext = {
env: {
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000301",
PASEO_TEST_FLAG: "codex-launch-value",
},
};
const env = buildCodexAppServerEnv(
{
env: {
PASEO_AGENT_ID: "runtime-value",
PASEO_TEST_FLAG: "runtime-test-value",
},
},
launchContext.env,
);
expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
});
test("projects request_user_input into a question permission and running timeline tool call", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
void asInternals(session).handleToolApprovalRequest({
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-1",
name: "request_user_input",
status: "running",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
},
},
{
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: {
id: "permission-call-question-1",
provider: "codex",
name: "request_user_input",
kind: "question",
title: "Question",
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
input: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
metadata: {
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
},
},
]);
});
test("converts Codex collab agent notifications through the normal timeline path", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-normal-path",
tool: "spawnAgent",
status: "inProgress",
prompt: "Inspect the stream path.",
receiverThreadIds: [],
agentsStates: {},
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-sub-agent-normal-path",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Inspect the stream path.",
log: "",
actions: [],
},
},
},
]);
});
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-activity",
tool: "spawnAgent",
status: "completed",
prompt: "Report findings.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "pendingInit", message: null },
},
},
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "child-thread-1",
itemId: "child-message-1",
delta: "Found",
});
await asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "agentMessage",
id: "child-message-1",
text: "Found the path.",
},
});
await asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-1",
turn: { status: "completed" },
});
const timelineEvents = events.filter((event) => event.type === "timeline");
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
const finalItem = timelineEvents.at(-1)?.item;
expect(finalItem).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-activity",
name: "Sub-agent",
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Report findings.",
log: "",
actions: [],
},
});
const providerEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(providerEvents).toContainEqual(
expect.objectContaining({
type: "upsert",
id: "child-thread-1",
description: "Report findings.",
}),
);
expect(providerEvents).toContainEqual({
type: "timeline",
id: "child-thread-1",
item: {
type: "assistant_message",
messageId: "child-message-1",
text: "Found",
},
});
expect(providerEvents).toContainEqual({
type: "timeline",
id: "child-thread-1",
item: {
type: "assistant_message",
messageId: "child-message-1",
text: " the path.",
},
});
expect(providerEvents.at(-1)).toMatchObject({
type: "upsert",
id: "child-thread-1",
status: "completed",
});
});
test("keeps a settled child completed until Codex starts another child turn", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "call-settled-child",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn({ threadId: "settled-child-thread" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "settled-child-thread",
itemId: "late-child-message",
text: "Late trailing output.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
const providerUpserts = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(providerUpserts.at(-1)).toMatchObject({
id: "settled-child-thread",
status: "completed",
});
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
item: { callId: "call-settled-child", status: "completed" },
});
appServer.completesSubAgentActivity({
callId: "late-child-interaction",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
kind: "interacted",
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } });
appServer.startsTurn({
threadId: "settled-child-thread",
turnId: "next-child-turn",
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "running" } });
appServer.completeTurn({ threadId: "settled-child-thread" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("updates a registered child with its later native activity name", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-native-name-later",
tool: "spawnAgent",
status: "completed",
prompt: "Inspect the repository.",
receiverThreadIds: ["child-native-name-later"],
agentsStates: {
"child-native-name-later": { status: "pendingInit", message: null },
},
},
});
await asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "activity-native-name-later",
kind: "started",
agentThreadId: "child-native-name-later",
agentPath: "/root/research/investigator",
},
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
provider: "codex",
event: expect.objectContaining({
type: "upsert",
id: "child-native-name-later",
title: "Research / Investigator",
}),
}),
);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "call-native-name-later",
detail: {
type: "sub_agent",
subAgentType: "Research / Investigator",
description: "Inspect the repository.",
},
},
});
});
test("renders child MCP image results in the provider subagent timeline", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-image-child",
kind: "started",
agentThreadId: "image-child-thread",
agentPath: "/root/image-child",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "image-child-thread",
item: {
id: "child-mcp-image",
type: "mcpToolCall",
status: "completed",
server: "paseo",
tool: "browser_screenshot",
arguments: {},
result: {
content: [{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }],
},
},
});
const childItems = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "image-child-thread"
? [event.event.item]
: [],
);
expect(childItems).toHaveLength(2);
expect(childItems[0]).toMatchObject({ type: "tool_call", callId: "child-mcp-image" });
expect(childItems[1]).toMatchObject({ type: "assistant_message" });
if (childItems[1]?.type !== "assistant_message") {
throw new Error("Expected child image markdown");
}
const source = markdownImageSource(childItems[1].text);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
});
test("renders a child user message once across lifecycle notifications", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-user-child",
kind: "started",
agentThreadId: "user-child-thread",
agentPath: "/root/user-child",
},
});
const childUserMessage = {
type: "userMessage",
id: "child-user-message",
content: [{ type: "text", text: "Inspect this path." }],
};
await asInternals(session).handleNotification("item/started", {
threadId: "user-child-thread",
item: childUserMessage,
});
await asInternals(session).handleNotification("item/completed", {
threadId: "user-child-thread",
item: childUserMessage,
});
expect(
events.filter(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "user-child-thread" &&
event.event.item.type === "user_message",
),
).toHaveLength(1);
});
test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-child-1",
threadId: "child-thread-1",
agentPath: "/root/child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "child-thread-1",
itemId: "child-message-1",
text: "Child findings.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn({ threadId: "child-thread-1" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "thread-1",
itemId: "parent-message-1",
text: "Parent report.",
chunks: ["Parent ", "report."],
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
expect(result.finalText).toBe("Parent report.");
const assistantMessages = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantMessages.map((item) => item.messageId)).toEqual([
"parent-message-1",
"parent-message-1",
]);
expect(assistantMessages.map((item) => item.text).join("")).toBe("Parent report.");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("returns only the latest assistant item without its visual boundary", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Report twice, then finish.");
await appServer.waitForTurnStart();
appServer.says({
threadId: "thread-1",
itemId: "first-parent-message",
text: "First report.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "thread-1",
itemId: "second-parent-message",
text: "Second report.",
chunks: ["", "Second report."],
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
expect(result.finalText).toBe("Second report.");
expect(result.finalText).not.toContain("---");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("returns only the latest id-less assistant item", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Report twice, then finish.");
await appServer.waitForTurnStart();
appServer.says({ threadId: "thread-1", text: "First report." });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({ threadId: "thread-1", text: "Second report." });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
expect(result.finalText).toBe("Second report.");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("replays MultiAgentV2 child activity that arrives before its parent mapping", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "child-thread-early" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "child-thread-early",
itemId: "child-message-early",
text: "Early child findings.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn({ threadId: "child-thread-early" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.startsSubAgent({
callId: "spawn-child-early",
threadId: "child-thread-early",
agentPath: "/root/early-child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "thread-1",
itemId: "parent-message-after-early-child",
text: "Parent report after replay.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
expect(result.finalText).toBe("Parent report after replay.");
expect(result.timeline.filter((item) => item.type === "assistant_message")).toEqual([
{
type: "assistant_message",
messageId: "parent-message-after-early-child",
text: "Parent report after replay.",
},
]);
expect(result.timeline.findLast((item) => item.type === "tool_call")).toMatchObject({
type: "tool_call",
callId: "spawn-child-early",
status: "completed",
detail: { type: "sub_agent", log: "" },
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
id: "child-thread-early",
item: expect.objectContaining({ type: "assistant_message" }),
}),
}),
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps MultiAgentV2 interaction and interruption on the original child card", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.beginsSubAgentActivity({
callId: "message-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interacted",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesSubAgentActivity({
callId: "message-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interacted",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.says({
threadId: "child-thread-stable",
itemId: "stable-child-message",
text: "Still on the same card.",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.beginsSubAgentActivity({
callId: "interrupt-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interrupted",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesSubAgentActivity({
callId: "interrupt-child-stable",
threadId: "child-thread-stable",
agentPath: "/root/stable-child",
kind: "interrupted",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(
new Set(["spawn-child-stable"]),
);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-stable",
status: "canceled",
detail: { type: "sub_agent", log: "" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not reopen a completed MultiAgentV2 child on activity completion", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.completeTurn({ threadId: "child-thread-fast" });
await new Promise<void>((resolve) => setImmediate(resolve));
const activity = {
callId: "spawn-child-fast",
threadId: "child-thread-fast",
agentPath: "/root/fast-child",
kind: "started" as const,
};
appServer.beginsSubAgentActivity(activity);
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesSubAgentActivity(activity);
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-fast",
status: "completed",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preserves a completed child status when replaying a late compaction", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.completeTurn({ threadId: "child-late-compaction" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesCompaction({
threadId: "child-late-compaction",
itemId: "late-child-compaction",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.startsSubAgent({
callId: "spawn-child-late-compaction",
threadId: "child-late-compaction",
agentPath: "/root/late-compaction",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-late-compaction",
status: "completed",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("routes legacy child tools to the child timeline", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the implementation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "spawn-legacy-tool-child",
threadId: "legacy-tool-child",
agentPath: "/root/legacy-tool-child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
const command = {
threadId: "legacy-tool-child",
callId: "legacy-child-command",
command: "printf child",
output: "child output",
};
appServer.runsLegacyCommand(command);
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesCommand(command);
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.appliesLegacyPatch({
threadId: "legacy-tool-child",
callId: "legacy-child-patch",
path: "/workspace/project/src/child.ts",
diff: "@@\n-old\n+new\n",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn({ threadId: "legacy-tool-child" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(
new Set(["spawn-legacy-tool-child"]),
);
const finalToolCall = toolCalls.at(-1);
expect(finalToolCall).toMatchObject({
callId: "spawn-legacy-tool-child",
status: "completed",
detail: { type: "sub_agent", log: "" },
});
const childToolCalls = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "legacy-tool-child" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
new Set(["shell", "edit"]),
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-root",
kind: "started",
agentThreadId: "child-thread-root",
agentPath: "/root/child",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-root",
item: {
type: "subAgentActivity",
id: "spawn-grandchild",
kind: "started",
agentThreadId: "grandchild-thread",
agentPath: "/root/child/grandchild",
},
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "grandchild-thread",
itemId: "grandchild-message",
delta: "Grandchild findings.",
});
await asInternals(session).handleNotification("turn/completed", {
threadId: "grandchild-thread",
turn: { status: "completed" },
});
const providerSubagents = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(providerSubagents).toContainEqual(
expect.objectContaining({
id: "child-thread-root",
parentSubagentId: null,
}),
);
expect(providerSubagents).toContainEqual(
expect.objectContaining({
id: "grandchild-thread",
parentSubagentId: "child-thread-root",
}),
);
const beforeParentCompletes = events
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
.map((event) => event.item);
expect(new Set(beforeParentCompletes.map((item) => item.callId))).toEqual(
new Set(["spawn-child-root"]),
);
expect(beforeParentCompletes.at(-1)).toMatchObject({
callId: "spawn-child-root",
status: "running",
detail: { type: "sub_agent", log: "" },
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "grandchild-thread",
item: {
type: "assistant_message",
messageId: "grandchild-message",
text: "Grandchild findings.",
},
},
}),
);
await asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-root",
turn: { status: "completed" },
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: { callId: "spawn-child-root", status: "completed" },
});
});
test("keeps a nested child under its spawning parent after a root interaction", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-root",
kind: "started",
agentThreadId: "child-thread-root",
agentPath: "/root/child",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-root",
item: {
type: "subAgentActivity",
id: "spawn-grandchild",
kind: "started",
agentThreadId: "grandchild-thread",
agentPath: "/root/child/grandchild",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "wait-for-grandchild",
tool: "wait",
status: "completed",
receiverThreadIds: ["grandchild-thread"],
agentsStates: {
"grandchild-thread": { status: "running", message: null },
},
},
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "grandchild-thread",
itemId: "grandchild-after-wait",
delta: "Still nested.",
});
const grandchildUpserts = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.id === "grandchild-thread"
? [event.event]
: [],
);
expect(grandchildUpserts.map((event) => event.parentSubagentId)).toEqual([
"child-thread-root",
"child-thread-root",
]);
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "grandchild-thread",
item: {
type: "assistant_message",
messageId: "grandchild-after-wait",
text: "Still nested.",
},
},
}),
);
});
test("never treats an unmapped foreign terminal as the root terminal", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/completed", {
threadId: "unmapped-child-thread",
turn: { status: "completed" },
});
expect(events).toEqual([]);
await asInternals(session).handleNotification("turn/completed", {
threadId: "test-thread",
turn: { status: "completed" },
});
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
await asInternals(session).handleNotification("turn/started", {
threadId: "test-thread",
turn: { id: "next-root-turn" },
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-reused-foreign-thread",
kind: "started",
agentThreadId: "unmapped-child-thread",
agentPath: "/root/reused-child",
},
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: { callId: "spawn-reused-foreign-thread", status: "running" },
});
});
test("routes msg-scoped legacy Codex events to their child thread", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-legacy-envelope-child",
kind: "started",
agentThreadId: "legacy-envelope-child",
agentPath: "/root/legacy-envelope-child",
},
});
await asInternals(session).handleNotification("codex/event/exec_command_begin", {
msg: {
type: "exec_command_begin",
threadId: "legacy-envelope-child",
call_id: "child-command",
command: "pwd",
},
});
await asInternals(session).handleNotification("codex/event/task_complete", {
msg: {
type: "task_complete",
thread_id: "legacy-envelope-child",
},
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "child-command",
),
).toBe(false);
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "legacy-envelope-child",
item: expect.objectContaining({
type: "tool_call",
callId: "child-command",
status: "running",
}),
},
}),
);
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "spawn-legacy-envelope-child",
status: "completed",
},
});
await asInternals(session).handleNotification("codex/event/task_complete", {
msg: { type: "task_complete" },
});
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
});
test("discovers a MultiAgentV2 child from a legacy-only lifecycle notification", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
const child = waitForProviderSubagent(session, "legacy-only-child-thread");
const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child");
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.startsLegacyOnlySubAgent({
callId: "spawn-legacy-only-child",
threadId: "legacy-only-child-thread",
agentPath: "/root/legacy-only-child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(child).resolves.toMatchObject({
type: "provider_subagent",
provider: "codex",
turnId: "codex-turn-0",
event: {
type: "upsert",
id: "legacy-only-child-thread",
status: "running",
},
});
await expect(spawn).resolves.toMatchObject({
type: "timeline",
provider: "codex",
turnId: "codex-turn-0",
item: {
type: "tool_call",
callId: "spawn-legacy-only-child",
status: "running",
detail: {
type: "sub_agent",
description: "legacy-only-child",
},
},
});
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("reports when Codex rejects a foreground turn interrupt", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": async () => {
throw new Error("A foreground turn is already active");
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Wait for the child.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" });
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active");
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("treats Codex already having no active turn as an acknowledged interrupt", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({
__jsonRpcError: { code: -32600, message: "no active turn to interrupt" },
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Wait for the child.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" });
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(session.interrupt()).resolves.toBeUndefined();
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("waits for Codex to identify an accepted turn before interrupting it", async () => {
const interruptedTurns: unknown[] = [];
const appServer = createFakeCodexAppServer({
"turn/interrupt": async (params) => {
interruptedTurns.push(params);
return {};
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Start working.");
await appServer.waitForTurnStart();
const interruptPromise = session.interrupt();
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" });
await new Promise<void>((resolve) => setImmediate(resolve));
await interruptPromise;
expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]);
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("acknowledges interruption when the accepted turn terminates before identification", async () => {
const interruptedTurns: unknown[] = [];
const appServer = createFakeCodexAppServer({
"turn/interrupt": async (params) => {
interruptedTurns.push(params);
return {};
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const resultPromise = session.run("Finish before identification.");
await appServer.waitForTurnStart();
const interruptPromise = session.interrupt();
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await expect(interruptPromise).resolves.toBeUndefined();
await resultPromise;
expect(interruptedTurns).toEqual([]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("acknowledges interruption before Codex initializes a thread", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await expect(session.interrupt()).resolves.toBeUndefined();
await session.close();
});
test("cancels a cold start before it can issue a native turn", async () => {
const threadStart = deferred<{ thread: { id: string } }>();
const appServer = createFakeCodexAppServer({
"thread/start": () => threadStart.promise,
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const resultPromise = session.run("Start after the delayed thread.");
try {
await appServer.waitForRequest("thread/start");
let interruptSettled = false;
const interruptPromise = session.interrupt().then(() => {
interruptSettled = true;
return undefined;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(interruptSettled).toBe(false);
threadStart.resolve({ thread: { id: "thread-1" } });
await expect(interruptPromise).resolves.toBeUndefined();
await expect(resultPromise).rejects.toThrow("interrupted before reaching Codex");
expect(appServer.requests()).not.toContainEqual(
expect.objectContaining({ method: "turn/start" }),
);
appServer.assertNoErrors();
} finally {
threadStart.resolve({ thread: { id: "thread-1" } });
const nativeStart = await appServer.waitForTurnStart().catch(() => null);
if (nativeStart) {
appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
}
await resultPromise.catch(() => undefined);
await session.close();
}
});
test("interrupts an autonomous Codex turn identified by live notifications", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
session.activeForegroundTurnId = null;
session.client = {
request: async (method, params) => {
requests.push({ method, params });
return {};
},
};
await asInternals(session).handleNotification("turn/started", {
threadId: "test-thread",
turn: { id: "autonomous-turn" },
});
await session.interrupt();
expect(requests).toContainEqual({
method: "turn/interrupt",
params: {
threadId: "test-thread",
turnId: "autonomous-turn",
},
});
});
test("never replaces the root identity with an early child thread start", async () => {
const session = createSession();
await asInternals(session).handleNotification("thread/started", {
thread: { id: "child-thread-started-early" },
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-thread-started-early",
kind: "started",
agentThreadId: "child-thread-started-early",
agentPath: "/root/early-thread",
},
});
expect(session.currentThreadId).toBe("test-thread");
});
test("does not leak aggregate child telemetry into the root timeline", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-telemetry",
kind: "started",
agentThreadId: "child-thread-telemetry",
agentPath: "/root/telemetry-child",
},
});
const eventCountAfterSpawn = events.length;
await asInternals(session).handleNotification("turn/plan/updated", {
threadId: "child-thread-telemetry",
plan: [{ step: "Child-only plan", status: "inProgress" }],
});
expect(events).toHaveLength(eventCountAfterSpawn);
});
test("keeps child context compaction on the child timeline", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-child-compaction",
kind: "started",
agentThreadId: "child-thread-compaction",
agentPath: "/root/compacting-child",
},
});
await asInternals(session).handleNotification("item/started", {
threadId: "child-thread-compaction",
item: { type: "contextCompaction", id: "child-compaction" },
});
await asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-compaction",
item: { type: "contextCompaction", id: "child-compaction" },
});
const timelineItems = events.flatMap((event) =>
event.type === "timeline" ? [event.item] : [],
);
expect(timelineItems.every((item) => item.type === "tool_call")).toBe(true);
expect(
timelineItems.every(
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
),
).toBe(true);
expect(
events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
),
).toEqual([
{
type: "timeline",
id: "child-thread-compaction",
item: { type: "compaction", status: "loading" },
},
{
type: "timeline",
id: "child-thread-compaction",
item: { type: "compaction", status: "completed" },
},
]);
});
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
for (const child of ["compacted-child", "turn-ended-child"]) {
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: `spawn-${child}`,
kind: "started",
agentThreadId: child,
agentPath: `/root/${child}`,
},
});
await asInternals(session).handleNotification("item/started", {
threadId: child,
item: { type: "contextCompaction", id: `${child}-compaction` },
});
}
await asInternals(session).handleNotification("thread/compacted", {
threadId: "compacted-child",
});
await asInternals(session).handleNotification("item/completed", {
threadId: "compacted-child",
item: { type: "contextCompaction", id: "compacted-child-compaction" },
});
await asInternals(session).handleNotification("turn/completed", {
threadId: "turn-ended-child",
turn: { status: "completed" },
});
const compactions = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline"
? [
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
]
: [],
);
expect(compactions).toEqual([
"compacted-child loading",
"turn-ended-child loading",
"compacted-child completed",
"turn-ended-child completed",
]);
});
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-command-failure",
tool: "spawnAgent",
status: "completed",
prompt: "Fix the regression test-first.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "running", message: null },
},
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "commandExecution",
id: "child-failing-command",
status: "failed",
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
aggregatedOutput: "expected false to be true",
exitCode: 1,
error: { message: "Command failed" },
},
});
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Fix the regression test-first.",
},
});
});
test("does not synthesize a parent sub-agent failure from child error state alone", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-transient-child-error",
tool: "spawnAgent",
status: "completed",
prompt: "Validate the child agent result.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "error", message: "Sub-agent failed" },
},
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-transient-child-error",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Validate the child agent result.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method !== "thread/read") {
return {};
}
return {
thread: {
turns: [
{
items: [
{
type: "agentMessage",
id: "message-history",
text: "History loaded.",
timestamp: "2026-05-01T10:00:00.000Z",
},
{
type: "contextCompaction",
id: "compact-history",
createdAt: "2026-05-01T10:00:01.000Z",
},
],
},
],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(requests.map((request) => [request.method, request.params])).toEqual([
["thread/read", { threadId: "test-thread", includeTurns: false }],
["thread/read", { threadId: "test-thread", includeTurns: true }],
]);
expect(history).toEqual([
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-01T10:00:00.000Z",
item: {
type: "assistant_message",
text: "History loaded.",
messageId: "message-history",
},
},
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-01T10:00:01.000Z",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("retains native turn ids from persisted user messages", async () => {
const session = createSession();
session.client = {
request: vi.fn(async () => ({
thread: {
turns: [
{
id: "native-turn-1",
items: [
{
type: "userMessage",
id: "message-history",
content: [{ type: "text", text: "History prompt" }],
},
],
},
],
},
})),
};
await Array.fromAsync(session.streamHistory());
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
index: 0,
turnId: "native-turn-1",
});
});
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
const threadId = (params as { threadId?: string }).threadId;
if (threadId !== "test-thread") {
return {
thread: {
turns: [
{
items: [
{
type: "agentMessage",
id: `message-${threadId}`,
text: `History from ${threadId}`,
},
],
},
],
},
};
}
return {
thread: {
turns: [
{
items: [
{
type: "collabAgentToolCall",
id: "legacy-spawn-history",
tool: "spawnAgent",
status: "completed",
prompt: "Legacy child",
receiverThreadIds: ["legacy-child-thread"],
agentsStates: { "legacy-child-thread": { status: "completed" } },
},
{
type: "subAgentActivity",
id: "legacy-native-name-history",
kind: "started",
agentThreadId: "legacy-child-thread",
agentPath: "/root/sentinel_child",
},
{
type: "subAgentActivity",
id: "v2-spawn-history",
kind: "started",
agentThreadId: "v2-child-thread",
agentPath: "/root/v2-child",
},
],
},
],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
),
).toMatchObject([
{
type: "upsert",
id: "legacy-child-thread",
status: "completed",
title: "Sentinel child",
},
{ type: "upsert", id: "v2-child-thread", status: "completed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
),
).toEqual([
{
type: "timeline",
id: "legacy-child-thread",
item: {
type: "assistant_message",
messageId: "message-legacy-child-thread",
text: "History from legacy-child-thread",
},
},
{
type: "timeline",
id: "v2-child-thread",
item: {
type: "assistant_message",
messageId: "message-v2-child-thread",
text: "History from v2-child-thread",
},
},
]);
expect(
history
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
.map((event) => event.item),
).toMatchObject([
{
callId: "legacy-spawn-history",
status: "completed",
detail: {
type: "sub_agent",
description: "Legacy child",
subAgentType: "Sentinel child",
},
},
{
callId: "v2-spawn-history",
status: "completed",
detail: { type: "sub_agent", description: "v2-child" },
},
]);
const liveEvents: AgentStreamEvent[] = [];
session.subscribe((event) => liveEvents.push(event));
await asInternals(session).handleNotification("turn/started", {
threadId: "v2-child-thread",
turn: { id: "v2-child-turn-after-resume" },
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "v2-interaction-after-resume",
kind: "interacted",
agentThreadId: "v2-child-thread",
agentPath: "/root/v2-child",
},
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "v2-child-thread",
itemId: "v2-child-message-after-resume",
delta: "More findings after resume.",
});
const liveToolCalls = liveEvents.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(liveToolCalls.map((item) => item.callId))).toEqual(
new Set(["v2-spawn-history"]),
);
expect(liveToolCalls.at(-1)).toMatchObject({
status: "running",
detail: { type: "sub_agent", log: "" },
});
liveEvents.length = 0;
await asInternals(session).handleNotification("turn/started", {
threadId: "legacy-child-thread",
turn: { id: "legacy-child-turn-after-resume" },
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "legacy-child-thread",
itemId: "legacy-child-message-after-resume",
delta: "Legacy findings after resume.",
});
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
item: {
callId: "legacy-spawn-history",
status: "running",
detail: { type: "sub_agent", log: "" },
},
});
});
test("restores nested MultiAgentV2 ownership from persisted child threads", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
const threadId = (params as { threadId?: string }).threadId;
const itemsByThreadId = {
"test-thread": [
{
type: "subAgentActivity",
id: "spawn-persisted-child",
kind: "started",
agentThreadId: "persisted-child",
agentPath: "/root/persisted-child",
},
{
type: "collabAgentToolCall",
id: "root-wait-grandchild",
tool: "wait",
status: "completed",
receiverThreadIds: ["persisted-grandchild"],
agentsStates: { "persisted-grandchild": { status: "completed" } },
},
],
"persisted-child": [
{
type: "subAgentActivity",
id: "spawn-persisted-grandchild",
kind: "started",
agentThreadId: "persisted-grandchild",
agentPath: "/root/persisted-child/grandchild",
},
],
} as const;
const items = threadId ? itemsByThreadId[threadId as keyof typeof itemsByThreadId] : [];
return {
thread: {
turns: items ? [{ items }] : [],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
const upserts = history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(upserts).toEqual([
expect.objectContaining({ id: "persisted-child", parentSubagentId: null }),
expect.objectContaining({
id: "persisted-grandchild",
parentSubagentId: "persisted-child",
}),
]);
});
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
if ((params as { threadId?: string }).threadId !== "test-thread") {
return { thread: { turns: [] } };
}
return {
thread: {
turns: [
{
items: [
{
type: "subAgentActivity",
id: "child-started-history",
kind: "started",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:00:00.000Z",
},
{
type: "subAgentActivity",
id: "child-interacted-history",
kind: "interacted",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:01:00.000Z",
},
{
type: "subAgentActivity",
id: "child-interrupted-history",
kind: "interrupted",
agentThreadId: "history-child-thread",
agentPath: "/root/history-child",
timestamp: "2026-07-09T10:02:00.000Z",
},
],
},
],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "provider_subagent",
provider: "codex",
event: expect.objectContaining({
type: "upsert",
id: "history-child-thread",
status: "canceled",
}),
},
{
type: "timeline",
provider: "codex",
timestamp: "2026-07-09T10:00:00.000Z",
item: expect.objectContaining({
type: "tool_call",
callId: "child-started-history",
status: "canceled",
detail: expect.objectContaining({
type: "sub_agent",
description: "history-child",
}),
}),
},
]);
});
test("does not import a parent interaction from child history as another sub-agent", async () => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const threadId = (params as { threadId?: string }).threadId;
if (threadId === "test-thread") {
return {
thread: {
turns: [
{
items: [
{
type: "subAgentActivity",
id: "child-started",
kind: "started",
agentThreadId: "child-thread",
agentPath: "/root/child",
},
],
},
],
},
};
}
return {
thread: {
turns: [
{
items: [
{
type: "subAgentActivity",
id: "parent-interacted",
kind: "interacted",
agentThreadId: "test-thread",
agentPath: "/root",
},
],
},
],
},
};
},
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
{ sessionId: "test-thread" },
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const providerSubagentIds = new Set<string>();
for await (const event of session.streamHistory()) {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
providerSubagentIds.add(event.event.id);
}
}
expect(providerSubagentIds).toEqual(new Set(["child-thread"]));
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not register a parent interaction on a child thread as another sub-agent", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const providerSubagentIds = new Set<string>();
session.subscribe((event) => {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
providerSubagentIds.add(event.event.id);
}
});
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
const child = waitForProviderSubagent(session, "child-thread");
appServer.startsSubAgent({
callId: "child-started",
threadId: "child-thread",
agentPath: "/root/child",
});
await new Promise<void>((resolve) => setImmediate(resolve));
await child;
appServer.beginsSubAgentActivity({
callId: "parent-interacted",
threadId: "thread-1",
parentThreadId: "child-thread",
kind: "interacted",
agentPath: "/root",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await resultPromise;
expect(providerSubagentIds).toEqual(new Set(["child-thread"]));
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("uses Codex turn timestamps for timestamp-less persisted history items", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
if (method !== "thread/read") {
return {};
}
return {
thread: {
turns: [
{
startedAt: 1_778_832_941,
completedAt: 1_778_833_094,
items: [
{
type: "userMessage",
id: "user-history",
content: [{ type: "text", text: "Check OpenCode timestamps." }],
},
{
type: "agentMessage",
id: "message-history",
text: "History loaded.",
},
],
},
],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-15T08:15:41.000Z",
item: {
type: "user_message",
text: "Check OpenCode timestamps.",
messageId: "user-history",
},
},
{
type: "timeline",
provider: "codex",
timestamp: "2026-05-15T08:18:14.000Z",
item: {
type: "assistant_message",
text: "History loaded.",
messageId: "message-history",
},
},
]);
});
test("preserves Codex app-server assistant item ids in persisted history", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
if (method !== "thread/read") {
return {};
}
return {
thread: {
turns: [
{
items: [
{
type: "agentMessage",
id: "before-tool-message",
text: "I checked the workspace.",
},
{
type: "agentMessage",
id: "after-tool-message",
text: "The tests are green.",
},
],
},
],
},
};
}),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "assistant_message",
text: "I checked the workspace.",
messageId: "before-tool-message",
},
},
{
type: "timeline",
provider: "codex",
item: {
type: "assistant_message",
text: "The tests are green.",
messageId: "after-tool-message",
},
},
]);
});
test("captures live Codex user message ids from item events", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const userMessageItem = {
type: "userMessage",
id: "codex-user-live-1",
content: [{ type: "text", text: "Use the native Codex id." }],
};
await asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: userMessageItem,
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: userMessageItem,
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "user_message",
text: "Use the native Codex id.",
messageId: "codex-user-live-1",
},
},
]);
});
test("emits Codex context compaction markers from live thread items", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "compact-live",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "compact-live",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "loading",
},
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("completes a pending Codex compaction when its turn ends", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({
threadId: "thread-1",
itemId: "compact-without-completion",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not complete a Codex compaction twice when its item finishes before the turn", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({
threadId: "thread-1",
itemId: "compact-completed-normally",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesCompaction({
threadId: "thread-1",
itemId: "compact-completed-normally",
});
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not let a late compaction completion consume the current pending item", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await new Promise<void>((resolve) => setImmediate(resolve));
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ status: "failed", terminalType: "turn_failed" },
{ status: "interrupted", terminalType: "turn_canceled" },
])("completes a pending compaction before a $status turn", async ({ status, terminalType }) => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn({
status,
error: status === "failed" ? { message: "Compaction failed" } : null,
});
await new Promise<void>((resolve) => setImmediate(resolve));
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", terminalType]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("emits and dedupes Codex thread/compacted notifications", async () => {
const session = createSession();
session.activeForegroundTurnId = null;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn",
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "legacy-compact-item",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("emits consecutive Codex thread/compacted notifications", async () => {
const session = createSession();
session.activeForegroundTurnId = null;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn-1",
});
await asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn-2",
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn-1",
item: {
type: "compaction",
status: "completed",
},
},
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn-2",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no tool-call found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
}
return {};
}),
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no tool-call found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{
method: "thread/resume",
params: { threadId: "archived-thread-id", excludeTurns: true },
},
]);
});
test("appends blank-line spacing to /goal status messages", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true });
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
return {};
}),
};
const handler = session.tryHandleOutOfBand?.("/goal ship feature");
expect(handler).not.toBeNull();
const events: AgentStreamEvent[] = [];
await handler?.run({ emit: (event) => events.push(event) });
expect(requests).toContainEqual({
method: "thread/goal/set",
params: {
threadId: "test-thread",
objective: "ship feature",
status: "active",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "assistant_message",
text: "Goal set: ship feature\n\n",
},
},
]);
});
test("lists /compact and sends Codex compaction out of band", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "skills/list") {
return { data: [] };
}
return {};
}),
};
await expect(session.listCommands?.()).resolves.toContainEqual({
name: "compact",
description: "Summarize conversation to prevent hitting the context limit",
argumentHint: "",
kind: "command",
});
const handler = session.tryHandleOutOfBand?.("/compact");
expect(handler).not.toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await handler?.run({ emit: (event) => events.push(event) });
await asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "manual-compact",
},
});
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "manual-compact",
},
});
expect(requests).toContainEqual({
method: "thread/compact/start",
params: { threadId: "test-thread" },
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "loading",
trigger: "manual",
},
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "completed",
trigger: "manual",
},
},
]);
});
test("maps question responses from headers back to question ids and completes the tool call", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const pendingResponse = asInternals(session).handleToolApprovalRequest({
itemId: "call-question-2",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
});
await session.respondToPermission("permission-call-question-2", {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
});
await expect(pendingResponse).resolves.toEqual({
answers: {
favorite_drink: { answers: ["Tea"] },
},
});
expect(events.at(-2)).toEqual({
type: "permission_resolved",
provider: "codex",
turnId: "test-turn",
requestId: "permission-call-question-2",
resolution: {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
},
});
expect(events.at(-1)).toEqual({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-2",
name: "request_user_input",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
answers: {
favorite_drink: ["Tea"],
},
},
},
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-1" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [
{ step: "Inspect the existing auth flow", status: "completed" },
{ step: "Implement the button behavior", status: "pending" },
],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events.at(-3)).toEqual({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: session.getPendingPermissions()[0]?.id,
name: "plan_approval",
status: "running",
error: null,
detail: {
type: "plan",
text: "- Inspect the existing auth flow\n- Implement the button behavior",
},
},
});
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [
expect.objectContaining({
id: "dismiss",
label: "Dismiss",
behavior: "deny",
}),
expect.objectContaining({
id: "implement",
label: "Implement",
behavior: "allow",
}),
],
}),
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: undefined,
});
});
test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-thread-item" },
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "plan-item-1",
type: "plan",
text: "- Inspect README\n- Add a short note",
},
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).not.toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "tool_call",
status: "completed",
detail: expect.objectContaining({ type: "plan" }),
}),
}),
);
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: {
plan: "- Inspect README\n- Add a short note",
},
}),
});
});
test("replaces a pending synthetic plan approval when a later plan completes", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-first" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the first plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-second" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the revised plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({
kind: "plan",
input: { plan: "- Implement the revised plan" },
}),
]);
});
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
await session.startTurn("Revise the plan to include tests");
expect(session.getPendingPermissions()).toEqual([]);
expect(events).toContainEqual({
type: "permission_resolved",
provider: "codex",
requestId: pendingPlan!.id,
resolution: {
behavior: "deny",
message: "Dismissed by a new prompt",
},
});
});
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
let continuePromptSetup: (() => void) | undefined;
let markPromptSetupStarted: (() => void) | undefined;
const promptSetupStarted = new Promise<void>((resolve) => {
markPromptSetupStarted = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") {
markPromptSetupStarted?.();
await new Promise<void>((resolve) => {
continuePromptSetup = resolve;
});
return { data: ["test-thread"] };
}
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptSetupStarted;
expect(session.getPendingPermissions()).toEqual([]);
await expect(
session.respondToPermission(pendingPlan!.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow(
`No pending Codex app-server permission request with id '${pendingPlan!.id}'`,
);
continuePromptSetup?.();
await startTurn;
});
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
let acceptPrompt: (() => void) | undefined;
let markPromptRequested: (() => void) | undefined;
const promptRequested = new Promise<void>((resolve) => {
markPromptRequested = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") {
markPromptRequested?.();
return await new Promise<void>((resolve) => {
acceptPrompt = resolve;
});
}
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptRequested;
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the newer plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
acceptPrompt?.();
await startTurn;
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({ input: { plan: "- Implement the newer plan" } }),
]);
});
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") throw new Error("Prompt rejected");
throw new Error(`Unexpected request: ${method}`);
},
});
await expect(session.startTurn("Revise the plan")).rejects.toThrow("Prompt rejected");
expect(session.getPendingPermissions()).toEqual([]);
});
test("emits imageView paths with spaces as valid assistant markdown images", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
item: {
id: "image-view-1",
type: "imageView",
path: "/tmp/paseo image.png",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "assistant_message",
text: "\n\n![Image](file:///tmp/paseo%20image.png)\n\n",
},
},
]);
});
test.each([
["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"],
["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"],
])(
"emits imageGeneration thread items with %s as assistant markdown images",
async (_fieldName, imageFields, expectedPath) => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
item: {
id: `image-generation-${_fieldName}`,
type: "imageGeneration",
status: "completed",
...imageFields,
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "assistant_message",
text: `\n\n![Image](${expectedPath})\n\n`,
},
},
]);
},
);
test("materializes imageGeneration base64 results before rendering markdown", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
item: {
id: "image-generation-base64",
type: "imageGeneration",
status: "completed",
result: `data:image/png;base64,${ONE_BY_ONE_PNG_BASE64}`,
},
});
expect(events).toHaveLength(1);
const event = events[0];
expect(event).toMatchObject({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message" },
});
if (event?.type !== "timeline" || event.item.type !== "assistant_message") {
throw new Error("Expected assistant timeline event");
}
expect(event.item.text).not.toContain("data:image");
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(event.item.text);
expect(source).toMatch(/paseo-attachments(?:-[^\\/]+)?[\\/].+\.png$/);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
});
test("mcpToolCall image content emits a completed tool call plus assistant markdown image", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
const timelineEvents: Array<Extract<AgentStreamEvent, { type: "timeline" }>> = [];
const timelineItemsReceived = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for MCP image timeline events"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (event.type !== "timeline") {
return;
}
timelineEvents.push(event);
if (timelineEvents.length === 2) {
clearTimeout(timeout);
unsubscribe();
resolve();
}
});
});
try {
const { turnId } = await session.startTurn("capture a browser screenshot");
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/completed",
params: {
item: {
id: "mcp-browser-screenshot",
type: "mcpToolCall",
status: "completed",
server: "paseo",
tool: "browser_screenshot",
arguments: { browserId: "11111111-1111-4111-8111-111111111111" },
result: {
content: [
{ type: "text", text: "Captured browser screenshot (1x1)." },
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
],
structuredContent: {
ok: true,
result: {
command: "screenshot",
browserId: "11111111-1111-4111-8111-111111111111",
mimeType: "image/png",
width: 1,
height: 1,
},
},
},
},
},
})}\n`,
);
await timelineItemsReceived;
expect(timelineEvents).toEqual([
{
type: "timeline",
provider: "codex",
turnId,
item: {
type: "tool_call",
callId: "mcp-browser-screenshot",
name: "paseo.browser_screenshot",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { browserId: "11111111-1111-4111-8111-111111111111" },
output: {
content: [
{ type: "text", text: "Captured browser screenshot (1x1)." },
{ type: "text", text: "[image]" },
],
structuredContent: {
ok: true,
result: {
command: "screenshot",
browserId: "11111111-1111-4111-8111-111111111111",
mimeType: "image/png",
width: 1,
height: 1,
},
},
},
},
},
},
{
type: "timeline",
provider: "codex",
turnId,
item: expect.objectContaining({ type: "assistant_message" }),
},
]);
const imageEvent = timelineEvents[1];
if (imageEvent.item.type !== "assistant_message") {
throw new Error("Expected assistant image timeline event");
}
expect(JSON.stringify(events)).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(imageEvent.item.text);
expect(source).toMatch(/paseo-attachments(?:-[^\\/]+)?[\\/].+\.png$/);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("ignores incomplete imageGeneration thread items without failing the turn", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
expect(
async () =>
await asInternals(session).handleNotification("item/completed", {
item: {
id: "image-generation-incomplete",
type: "imageGeneration",
status: "in_progress",
},
}),
).not.toThrow();
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).toEqual([
{
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: undefined,
},
]);
});
test.each([
[
"a usage limit",
{ type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 },
{ type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 },
],
["no failure details", null, { message: "Image generation failed" }],
])(
"emits failed imageGeneration items with %s as failed tool calls",
async (_label, failure, expectedError) => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/completed", {
item: {
id: "image-generation-failed",
type: "imageGeneration",
status: "failed",
revisedPrompt: "paint a blue whale",
result: "",
transparentBackground: null,
failure,
savedPath: null,
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "image-generation-failed",
name: "image_generation",
status: "failed",
error: expectedError,
detail: {
type: "unknown",
input: { prompt: "paint a blue whale" },
output: null,
},
},
},
]);
},
);
test("keeps failed imageGeneration items in persisted history", async () => {
const session = createSession();
session.client = {
request: vi.fn(async () => ({
thread: {
turns: [
{
items: [
{
type: "imageGeneration",
id: "image-generation-failed",
status: "failed",
revisedPrompt: "paint a blue whale",
result: "",
transparentBackground: null,
failure: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null },
savedPath: null,
},
],
},
],
},
})),
};
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "image-generation-failed",
name: "image_generation",
status: "failed",
error: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null },
detail: {
type: "unknown",
input: { prompt: "paint a blue whale" },
output: null,
},
},
},
]);
});
test("emits usage_updated on token usage updates and keeps usage on turn completion", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("thread/tokenUsage/updated", {
tokenUsage: {
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
},
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
});
test("streams Codex assistant message deltas and does not replay completed text", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-1",
delta: "Hel",
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-1",
delta: "lo",
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "assistant-item-1",
type: "agentMessage",
text: "Hello",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "Hel", messageId: "assistant-item-1" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "lo", messageId: "assistant-item-1" },
},
]);
});
test("emits only the missing assistant suffix when completed text extends streamed deltas", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-2",
delta: "Hel",
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-2",
delta: "lo",
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "assistant-item-2",
type: "agentMessage",
text: "Hello!",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "Hel", messageId: "assistant-item-2" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "lo", messageId: "assistant-item-2" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "!", messageId: "assistant-item-2" },
},
]);
});
test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-3",
delta:
"I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.",
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "assistant-item-3",
type: "agentMessage",
text: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.",
},
});
await asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-4",
delta:
"CI is still cooking. I’m staying on the current run rather than jumping around, because the first red job will tell us exactly whether anything else needs work.",
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "assistant_message",
messageId: "assistant-item-3",
text: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.",
},
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "assistant_message",
messageId: "assistant-item-4",
text: "\n\n---\n\nCI is still cooking. I’m staying on the current run rather than jumping around, because the first red job will tell us exactly whether anything else needs work.",
},
},
]);
});
test("streams Codex reasoning deltas and does not replay completed reasoning", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-1",
delta: "Think",
});
await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-1",
delta: "ing",
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "reasoning-item-1",
type: "reasoning",
summary: ["Thinking"],
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "reasoning", text: "Think" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "reasoning", text: "ing" },
},
]);
});
test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-2",
delta: "Think",
});
await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-2",
delta: "ing",
});
await asInternals(session).handleNotification("item/completed", {
item: {
id: "reasoning-item-2",
type: "reasoning",
summary: ["Thinking!"],
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "reasoning", text: "Think" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "reasoning", text: "ing" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "reasoning", text: "!" },
},
]);
});
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-2" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the new flow", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: true,
});
// The session returns the follow-up prompt instead of calling startTurn directly.
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
expect(result).toBeDefined();
expect(result!.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."),
);
expect(events.at(-1)).toEqual({
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
});
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: false },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-3" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the safe flow", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: false,
});
expect(result?.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."),
);
});
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
asInternals(session).collaborationModes = [
{
name: "Code",
mode: "code",
developer_instructions: "Built-in code mode",
},
{
name: "Plan",
mode: "plan",
developer_instructions: "Built-in plan mode",
},
];
asInternals(session).refreshResolvedCollaborationMode();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-4" },
});
await asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the fast flow", status: "pending" }],
});
await asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(permissionRequest).toBeDefined();
if (!permissionRequest) {
throw new Error("Expected synthetic plan approval permission");
}
const result = await session.respondToPermission(permissionRequest.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(result?.followUpPrompt).toEqual(expect.any(String));
await session.startTurn(result!.followUpPrompt!);
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "code",
}),
}),
);
});
});
describe("Codex importable sessions", () => {
test("serializes concurrent app-server startups while allowing all scans to complete", async () => {
const firstEntered = deferred<void>();
const releaseFirst = deferred<void>();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
spawned++;
return createCodexAppServerChildProcess();
}),
_createCodexClient: () => ({
request: async (method) => {
if (method === "initialize") {
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
}
if (method === "thread/list") return { data: [] };
throw new Error(`Unexpected Codex method: ${method}`);
},
notify: () => {},
dispose: async () => {
disposed++;
},
}),
});
const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 }));
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const results = await Promise.all(scans);
expect(results).toEqual(Array.from({ length: 24 }, () => []));
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(24);
});
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
// the caller asks for, and hands back a cursor for the remaining threads.
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const params = (input ?? {}) as {
limit?: number;
cursor?: string;
cwd?: string;
sortKey?: string;
};
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
const matching = threads
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
const offset = Number(params.cursor ?? 0);
const page = matching.slice(
offset,
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
);
const nextOffset = offset + page.length;
return {
data: page,
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
};
};
}
function threadsUsedInReverseCreationOrder(count: number) {
// The most recently used thread is also the oldest one created, so a scan
// that follows Codex's creation-time page order misses it.
return Array.from({ length: count }, (_, index) => ({
id: `thread-${index}`,
cwd: "/workspace/project-a",
preview: `Session ${index}`,
createdAt: index,
updatedAt: count - index,
}));
}
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
const threads = threadsUsedInReverseCreationOrder(250);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler([
...threads,
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
]),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({
limit: 240,
cwd: "/workspace/project-a",
});
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 240).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps the scanned window on the most recently used threads", async () => {
const threads = threadsUsedInReverseCreationOrder(550);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler(threads),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 500).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
// A thread used while the scan is paging sorts to the front and comes back
// on the next page.
const appServer = createFakeCodexAppServer({
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
return cursor
? {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
],
nextCursor: null,
}
: {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
],
nextCursor: "page-2",
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"thread-1",
"thread-2",
"thread-3",
]);
appServer.assertNoErrors();
});
test("stops scanning when Codex pages without handing back a new thread", async () => {
const appServer = createFakeCodexAppServer({
// A cursor cycle: the two pages after the first serve threads the scan
// already holds and point at each other, so it can never fill its window.
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
return { data: [thread], nextCursor: "page-a" };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
appServer.assertNoErrors();
});
// Codex filters thread/list by model provider: with no `modelProviders` it
// returns only threads of the provider its own config selects, an empty
// list returns every provider, and a list returns only those providers.
function providerFilteringThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const { modelProviders } = (input ?? {}) as { modelProviders?: string[] };
const included = (thread: Record<string, unknown>) => {
if (modelProviders === undefined) return thread.modelProvider === "openai";
if (modelProviders.length === 0) return true;
return modelProviders.includes(String(thread.modelProvider));
};
return { data: threads.filter(included), nextCursor: null };
};
}
const threadsByModelProvider = [
{ id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 },
{ id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 },
{
id: "new-custom-thread",
cwd: "/workspace/project-a",
modelProvider: "paseo:my-codex",
updatedAt: 3,
},
{
id: "other-custom-thread",
cwd: "/workspace/project-a",
modelProvider: "paseo-my-codex",
updatedAt: 4,
},
];
test("a custom Codex provider lists its old and namespaced threads without other providers", async () => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
});
const provider = createProviderWithFakeAppServer(appServer, {
runtimeSettings: {
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
},
customProvider: { id: "my-codex", label: "My Codex", extends: "codex" },
});
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"custom-thread",
"new-custom-thread",
]);
appServer.assertNoErrors();
});
test.each(["openai", "ollama", "lmstudio", "amazon-bedrock", "amazon-bedrock-runtime", "gcp"])(
"a custom provider named %s excludes native built-in sessions",
async (providerId) => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler([
{ id: "native-thread", cwd: "/workspace/project-a", modelProvider: providerId },
{
id: "custom-thread",
cwd: "/workspace/project-a",
modelProvider: `paseo:${providerId}`,
},
]),
});
const provider = createProviderWithFakeAppServer(appServer, {
runtimeSettings: {
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
},
customProvider: { id: providerId, label: "Custom Codex", extends: "codex" },
});
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]);
appServer.assertNoErrors();
},
);
test("stock Codex keeps listing only its own threads", async () => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
id: "thread-a1",
cwd: "/workspace/project-a",
preview: "First A session",
name: "Named first A session",
createdAt: 1000,
updatedAt: 2000,
},
{
id: "thread-a2",
cwd: "/workspace/project-a",
preview: "Second A session",
createdAt: 1500,
updatedAt: 2500,
},
{
id: "thread-b1",
cwd: "/workspace/project-b",
preview: "B session",
createdAt: 3000,
updatedAt: 4000,
},
];
const calls: Array<{ method: string; params?: unknown }> = [];
const fakeClient = {
request: async (method: string, params?: unknown) => {
calls.push({ method, params });
if (method === "thread/list") return { data: allThreads };
return {};
},
notify: () => {},
dispose: async () => {},
};
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
_createCodexClient: () => fakeClient,
appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess),
});
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId).sort()).toEqual([
"thread-a1",
"thread-a2",
]);
expect(sessions.every((session) => session.cwd === "/workspace/project-a")).toBe(true);
expect(sessions[0]).toEqual(
expect.objectContaining({
providerHandleId: "thread-a1",
title: "Named first A session",
firstPromptPreview: "First A session",
lastPromptPreview: "First A session",
}),
);
expect(calls).toEqual([
{
method: "initialize",
params: {
clientInfo: {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
},
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
},
},
{
method: "thread/list",
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
},
]);
});
});
describe("Codex denied plan approvals", () => {
function planApprovalRows(events: AgentStreamEvent[]) {
return events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "plan_approval" &&
event.item.status === "completed",
);
}
function createPlanSession(): { session: CodexTestSession; events: AgentStreamEvent[] } {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
castInternals<{ emitSyntheticPlanApprovalRequest: (planText: string) => void }>(
session,
).emitSyntheticPlanApprovalRequest("Ship the thing");
return { session, events };
}
function pendingPlanRequestId(session: CodexTestSession): string {
const [request] = session.getPendingPermissions?.() ?? [];
if (!request) throw new Error("expected a pending plan permission");
return request.id;
}
test("answering the card with a denial records the plan decision", async () => {
const { session, events } = createPlanSession();
const requestId = pendingPlanRequestId(session);
await session.respondToPermission?.(requestId, {
behavior: "deny",
message: "The user answered with a message instead of approving.",
});
const [row] = planApprovalRows(events);
expect(row).toBeDefined();
expect((row as { item: { detail: unknown; metadata?: unknown } }).item).toMatchObject({
detail: { type: "plan", text: "Ship the thing" },
metadata: { approved: false },
});
});
test("a plan superseded by a new prompt records the same decision", () => {
const { session, events } = createPlanSession();
castInternals<{ dismissPendingPlanApprovals: (message: string) => void }>(
session,
).dismissPendingPlanApprovals("Dismissed by a new prompt");
// dismissPendingPlanApprovals goes straight to resolvePlanPermission, so a
// row emitted from the response handler would miss this route entirely.
const [row] = planApprovalRows(events);
expect(row).toBeDefined();
expect((row as { item: { detail: unknown; metadata?: unknown } }).item).toMatchObject({
detail: { type: "plan", text: "Ship the thing" },
metadata: { approved: false },
});
});
});
describe("Codex interactive resume startup", () => {
test("delivers an autonomous turn that starts during resume to the first subscriber", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({}),
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
appServer.says({ threadId: "saved-thread", text: "Working on the goal" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ modeId: "full-access" }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("Working on the goal"),
),
).toBe(true);
await session.interrupt();
expect(
appServer.requests().find((request) => request.method === "turn/interrupt"),
).toMatchObject({ params: { threadId: "saved-thread", turnId: "autonomous-turn" } });
unsubscribe();
const laterEvents: AgentStreamEvent[] = [];
session.subscribe((event) => laterEvents.push(event));
expect(laterEvents.filter((event) => event.type !== "background_work")).toEqual([]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test.each([false, true])(
"applies resumed goal events before returning (completed during resume: %s)",
async (completedDuringResume) => {
const appServer = createFakeCodexAppServer({
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
if (completedDuringResume) appServer.completeTurn({ threadId: "saved-thread" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
const client: AgentClient = {
provider: "codex",
capabilities: session.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: async () => {
throw new Error("Unexpected create");
},
resumeSession: async () => {
await session.connect();
return session;
},
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
let id: string | undefined;
try {
const resumed = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "saved-thread" },
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
);
id = resumed.id;
expect(resumed.lifecycle).toBe(completedDuringResume ? "idle" : "running");
expect(manager.getAgent(id)?.lifecycle).toBe(completedDuringResume ? "idle" : "running");
} finally {
if (id) await manager.closeAgent(id);
await session.close();
appServer.assertNoErrors();
}
},
);
});
test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => {
let response: unknown = { data: [], nextCursor: null };
const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response });
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.startTurn("hello");
await appServer.waitForTurnStart();
expect(await session.canStopRuntime()).toBe(false);
appServer.completeTurn({ threadId: "thread-1" });
await new Promise<void>((resolve) => setImmediate(resolve));
await expect.poll(() => session.canStopRuntime()).toBe(true);
response = { data: [{ processId: "shell-1" }], nextCursor: null };
expect(await session.canStopRuntime()).toBe(false);
response = {};
await expect(session.canStopRuntime()).rejects.toThrow();
response = { __jsonRpcError: { code: -32601, message: "Method not found" } };
await expect(session.canStopRuntime()).rejects.toThrow("Method not found");
expect(
appServer
.requests()
.findLast((request) => request.method === "thread/backgroundTerminals/list"),
).toMatchObject({ params: { threadId: "thread-1" } });
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("publishes a materialized image before dispatching the following turn completion", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("generate an image");
const data = Buffer.alloc(1024 * 1024, 47).toString("base64");
const imageFrame =
JSON.stringify({
method: "item/completed",
params: {
item: {
id: "ordered-image",
type: "imageGeneration",
status: "completed",
result: { data, mimeType: "image/png" },
},
},
}) + "\n";
const completed = new Promise<void>((resolve) =>
session.subscribe((event) => {
if (event.type === "turn_completed") resolve();
}),
);
appServer.child.stdout.write(imageFrame);
appServer.child.stdout.write(
JSON.stringify({
method: "turn/completed",
params: { turn: { status: "completed", error: null } },
}) + "\n",
);
await completed;
await new Promise((resolve) => setTimeout(resolve, 5));
const imageIndex = events.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("![Image]"),
);
const completedIndex = events.findIndex((event) => event.type === "turn_completed");
expect(imageIndex).toBeGreaterThanOrEqual(0);
expect(imageIndex).toBeLessThan(completedIndex);
const imageEvent = events[imageIndex];
if (imageEvent.type !== "timeline" || imageEvent.item.type !== "assistant_message")
throw new Error("Expected image");
expect(existsSync(markdownImageSource(imageEvent.item.text))).toBe(true);
} finally {
await session.close();
}
});
describe("legacy multi-file diff budget", () => {
test.each([true, false])(
"retains later file diffs when a large patch is running: %s",
(running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "large-legacy-patch",
cwd: "/repo",
running,
success: true,
changes: [
{
path: "/repo/alpha.txt",
kind: "modify",
unified_diff: "@@ -1 +1 @@\n-old\n+" + "a".repeat(14000),
},
{ path: "/repo/beta.txt", kind: "modify", unified_diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
});
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit detail");
expect(item.detail.unifiedDiff).toContain("diff --git a/beta.txt b/beta.txt");
expect(item.detail.unifiedDiff).toContain("+large");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
},
);
});
describe("legacy multi-file move metadata", () => {
test.each([true, false])("preserves path-keyed moves while running: %s", (running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-move",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/old.txt": {
type: "update",
move_path: "/repo/new.txt",
unified_diff: "@@ -1 +1 @@\n-old\n+new\n",
},
"/repo/other.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-before\n+after\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("diff --git a/old.txt b/new.txt");
expect(item.detail.unifiedDiff).toContain("+++ b/new.txt");
});
});
describe("legacy move-only entries", () => {
test.each([true, false])(
"keeps a move-only file alongside another edit while running: %s",
(running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-move-only",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/old.txt": { type: "update", move_path: "/repo/new.txt", unified_diff: "" },
"/repo/second.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-small\n+large\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("rename from old.txt\nrename to new.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
},
);
});
describe("legacy empty additions", () => {
test.each([true, false])("keeps an empty new file while running: %s", (running) => {
const item = mapCodexPatchNotificationToToolCall({
callId: "legacy-empty",
cwd: "/repo",
running,
success: true,
changes: {
"/repo/empty.txt": { type: "add", content: "" },
"/repo/second.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-old\n+new\n" },
},
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain(
"diff --git a/empty.txt b/empty.txt\n--- /dev/null\n+++ b/empty.txt",
);
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
});
});