mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
* 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>
8215 lines
268 KiB
TypeScript
8215 lines
268 KiB
TypeScript
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\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\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");
|
||
});
|
||
});
|