Files
paseo/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
T

1375 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, 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 type {
AgentLaunchContext,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
interface CollaborationModeRecord {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
}
interface CodexSessionTestAccess {
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
refreshResolvedCollaborationMode(): void;
serviceTier: "fast" | null;
planModeEnabled: boolean;
collaborationModes: CollaborationModeRecord[];
config: AgentSessionConfig;
}
interface CodexClientLike {
request: (method: string, ...rest: unknown[]) => Promise<unknown>;
}
type CodexTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
activeForegroundTurnId: string | null;
client: CodexClientLike | null;
};
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> = {}): CodexTestSession {
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig(configOverrides),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as CodexTestSession;
session.connected = true;
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
}
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess;
}
describe("Codex app-server provider", () => {
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(() => true) as ChildProcessWithoutNullStreams["kill"];
const client = new __codexAppServerInternals.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("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(
__codexAppServerInternals.listCodexSkills(cwd, workspaceGitService),
).resolves.toContainEqual({
name: "shipper",
description: "Ship changes carefully.",
argumentHint: "",
});
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(
__codexAppServerInternals.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(
__codexAppServerInternals.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(
__codexAppServerInternals.toAgentUsage({
last: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("excludes invalid context window values", () => {
expect(
__codexAppServerInternals.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 = __codexAppServerInternals.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("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 = { request } as unknown as CodexClientLike;
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 = { request } as unknown as CodexClientLike;
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: "in a worktree, remember to use Claude for the UI",
text_elements: [],
},
],
}),
);
});
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 = __codexAppServerInternals.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("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.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 = __codexAppServerInternals.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 = __codexAppServerInternals.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 = __codexAppServerInternals.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", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
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("folds child-thread Codex activity into the parent sub-agent tool call", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
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 },
},
},
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "child-thread-1",
itemId: "child-message-1",
delta: "Found the path.",
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "agentMessage",
id: "child-message-1",
text: "Found the path.",
},
});
asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-1",
turn: { status: "completed" },
});
const timelineEvents = events.filter((event) => event.type === "timeline");
expect(timelineEvents).toHaveLength(4);
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: "Found the path.",
actions: [{ index: 1, toolName: "Assistant", summary: "Found the path." }],
},
});
});
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", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-1" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [
{ step: "Inspect the existing auth flow", status: "completed" },
{ step: "Implement the button behavior", status: "pending" },
],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.detail.type === "plan",
),
).toBe(false);
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: "reject",
label: "Reject",
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 emit Codex plan thread items as timeline cards while plan approval is pending", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-thread-item" },
});
asInternals(session).handleNotification("item/completed", {
item: {
id: "plan-item-1",
type: "plan",
text: "- Inspect README\n- Add a short note",
},
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).not.toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "tool_call",
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("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("thread/tokenUsage/updated", {
tokenUsage: {
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
},
});
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", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-1",
delta: "Hel",
});
asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-1",
delta: "lo",
});
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" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "lo" },
},
]);
});
test("emits only the missing assistant suffix when completed text extends streamed deltas", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-2",
delta: "Hel",
});
asInternals(session).handleNotification("item/agentMessage/delta", {
itemId: "assistant-item-2",
delta: "lo",
});
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" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "lo" },
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: { type: "assistant_message", text: "!" },
},
]);
});
test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
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.",
});
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.",
},
});
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",
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",
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", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-1",
delta: "Think",
});
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-1",
delta: "ing",
});
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", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-2",
delta: "Think",
});
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
itemId: "reasoning-item-2",
delta: "ing",
});
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));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-2" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the new flow", status: "pending" }],
});
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));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-3" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the safe flow", status: "pending" }],
});
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 = { request } as unknown as CodexClientLike;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-4" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the fast flow", status: "pending" }],
});
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",
}),
}),
);
});
});