Files
promptfoo--promptfoo/test/providers/openai-codex-app-server.test.ts
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

5374 lines
173 KiB
TypeScript

import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
import { trace } from '@opentelemetry/api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OpenAICodexAppServerProvider } from '../../src/providers/openai/codex-app-server';
import { providerRegistry } from '../../src/providers/providerRegistry';
import { mockProcessEnv } from '../util/utils';
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: mocks.spawn,
};
});
interface MockAppServer {
proc: any;
stdout: PassThrough;
stderr: PassThrough;
writes: string[];
send: (message: unknown) => void;
messages: () => any[];
}
function createMockAppServer(): MockAppServer {
const proc = new EventEmitter() as any;
const stdout = new PassThrough();
const stderr = new PassThrough();
const stdin = new PassThrough();
const writes: string[] = [];
proc.stdin = stdin;
proc.stdout = stdout;
proc.stderr = stderr;
proc.killed = false;
proc.exitCode = null;
proc.kill = vi.fn((signal?: NodeJS.Signals) => {
proc.killed = true;
proc.exitCode = 0;
proc.emit('exit', 0, signal ?? null);
return true;
});
stdin.write = vi.fn((chunk: any) => {
writes.push(String(chunk));
return true;
}) as any;
stdin.end = vi.fn(() => stdin) as any;
return {
proc,
stdout,
stderr,
writes,
send: (message: unknown) => {
stdout.write(`${JSON.stringify(message)}\n`);
},
messages: () =>
writes
.join('')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
}
async function waitForMessage(
server: MockAppServer,
predicate: (message: any) => boolean,
): Promise<any> {
let found: any;
await vi.waitFor(() => {
found = server.messages().find(predicate);
expect(found).toBeTruthy();
});
return found;
}
async function flushMicrotasks(): Promise<void> {
for (let attempt = 0; attempt < 5; attempt++) {
await Promise.resolve();
}
}
async function waitForMessageWithoutTimers(
server: MockAppServer,
predicate: (message: any) => boolean,
): Promise<any> {
for (let attempt = 0; attempt < 50; attempt++) {
const found = server.messages().find(predicate);
if (found) {
return found;
}
await flushMicrotasks();
}
throw new Error('Timed out waiting for mock app-server message');
}
interface RecordedSpan {
name: string;
attributes: Record<string, unknown>;
status?: { code: number; message?: string };
ended: boolean;
}
function installSpanRecorder(): RecordedSpan[] {
const spans: RecordedSpan[] = [];
const createSpan = (name: string, attributes: Record<string, unknown> = {}) => {
const entry: RecordedSpan = { name, attributes: { ...attributes }, ended: false };
spans.push(entry);
return {
addEvent: vi.fn(),
end: vi.fn(() => {
entry.ended = true;
}),
isRecording: vi.fn(() => true),
recordException: vi.fn(),
setAttribute: vi.fn((key: string, value: unknown) => {
entry.attributes[key] = value;
}),
setAttributes: vi.fn((values: Record<string, unknown>) => {
Object.assign(entry.attributes, values);
}),
setStatus: vi.fn((status: { code: number; message?: string }) => {
entry.status = status;
}),
spanContext: vi.fn(() => ({ traceId: 'trace', spanId: 'span' })),
updateName: vi.fn(),
};
};
vi.spyOn(trace, 'getTracer').mockReturnValue({
startSpan: (name: string, options?: { attributes?: Record<string, unknown> }) =>
createSpan(name, options?.attributes),
startActiveSpan: (
name: string,
options: { attributes?: Record<string, unknown> },
_parentContext: unknown,
callback: (span: unknown) => unknown,
) => callback(createSpan(name, options?.attributes)),
} as any);
return spans;
}
describe('OpenAICodexAppServerProvider', () => {
let originalOpenAiApiKey: string | undefined;
let originalCodexApiKey: string | undefined;
beforeEach(() => {
originalOpenAiApiKey = process.env.OPENAI_API_KEY;
originalCodexApiKey = process.env.CODEX_API_KEY;
mockProcessEnv({ OPENAI_API_KEY: undefined, CODEX_API_KEY: undefined });
vi.clearAllMocks();
mocks.spawn.mockReset();
});
afterEach(async () => {
await providerRegistry.shutdownAll();
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
mockProcessEnv({ OPENAI_API_KEY: originalOpenAiApiKey, CODEX_API_KEY: originalCodexApiKey });
});
it('initializes with safe defaults and validates config strictly', () => {
const provider = new OpenAICodexAppServerProvider();
expect(provider.id()).toBe('openai:codex-app-server');
expect(provider.requiresApiKey()).toBe(false);
expect(provider.toString()).toBe('[OpenAI Codex App Server Provider]');
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
sandbox_mode: 'readonly',
} as any,
}),
).toThrow(/Invalid OpenAI Codex app-server config: sandbox_mode/);
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
sandboxMode: 'read-only',
} as any,
}),
).toThrow(/Invalid OpenAI Codex app-server config: \(root\)/);
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
service_tier: 'fast',
model_reasoning_effort: 'none',
personality: 'pragmatic',
approval_policy: {
granular: {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: false,
mcp_elicitations: false,
},
},
collaboration_mode: {
mode: 'plan',
settings: {
model: 'gpt-5.4',
reasoning_effort: 'none',
developer_instructions: null,
},
},
},
}),
).not.toThrow();
for (const [model, effort] of [
['gpt-5.6-sol', 'max'],
['gpt-5.6-sol', 'ultra'],
['gpt-5.6-terra', 'max'],
['gpt-5.6-terra', 'ultra'],
['gpt-5.6-luna', 'max'],
] as const) {
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
model,
model_reasoning_effort: effort,
},
}),
).not.toThrow();
}
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
service_tier: 'auto',
} as any,
}),
).toThrow(/Invalid OpenAI Codex app-server config: service_tier/);
expect(
() =>
new OpenAICodexAppServerProvider({
config: {
personality: 'chatgpt',
} as any,
}),
).toThrow(/Invalid OpenAI Codex app-server config: personality/);
});
it('runs a complete stdio app-server turn and normalizes the response', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
apiKey: 'test-api-key',
codex_path_override: '/usr/local/bin/codex',
model: 'gpt-5.5',
service_tier: 'fast',
model_reasoning_effort: 'none',
reasoning_summary: 'detailed',
personality: 'friendly',
approvals_reviewer: 'auto_review',
base_instructions: 'You are evaluating repository quality.',
developer_instructions: 'Return concise, actionable output.',
collaboration_mode: {
mode: 'plan',
settings: {
model: 'gpt-5.5',
reasoning_effort: 'none',
developer_instructions: null,
},
},
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Summarize this repo');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
expect(initialize.params.clientInfo.name).toBe('promptfoo_codex_app_server');
expect(initialize.params.capabilities.experimentalApi).toBe(true);
server.send({ id: initialize.id, result: { userAgent: 'codex-test' } });
await waitForMessage(server, (message) => message.method === 'initialized');
const loginStart = await waitForMessage(
server,
(message) => message.method === 'account/login/start',
);
expect(loginStart.params).toEqual({ type: 'apiKey', apiKey: 'test-api-key' });
server.send({ id: loginStart.id, result: { type: 'apiKey' } });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(threadStart.params).toMatchObject({
approvalPolicy: 'never',
sandbox: 'read-only',
ephemeral: true,
model: 'gpt-5.5',
serviceTier: 'fast',
baseInstructions: 'You are evaluating repository quality.',
developerInstructions: 'Return concise, actionable output.',
personality: 'friendly',
approvalsReviewer: 'auto_review',
});
server.send({
id: threadStart.id,
result: {
thread: {
id: 'thr_test',
cwd: process.cwd(),
modelProvider: 'openai',
},
},
});
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params).toMatchObject({
threadId: 'thr_test',
input: [{ type: 'text', text: 'Summarize this repo', text_elements: [] }],
approvalPolicy: 'never',
model: 'gpt-5.5',
serviceTier: 'fast',
effort: 'none',
summary: 'detailed',
personality: 'friendly',
collaborationMode: {
mode: 'plan',
settings: {
model: 'gpt-5.5',
reasoning_effort: 'none',
developer_instructions: null,
},
},
});
server.send({ id: turnStart.id, result: { turn: { id: 'turn_test', status: 'inProgress' } } });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_test',
turnId: 'turn_test',
itemId: 'item_msg',
delta: 'Final ',
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_test',
turnId: 'turn_test',
itemId: 'item_msg',
delta: 'response',
},
});
server.send({
method: 'thread/tokenUsage/updated',
params: {
threadId: 'thr_test',
turnId: 'turn_test',
tokenUsage: {
last: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 5,
totalTokens: 15,
reasoningOutputTokens: 0,
},
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_test',
turnId: 'turn_test',
item: {
type: 'agentMessage',
id: 'item_msg',
text: 'Final response',
phase: null,
memoryCitation: null,
},
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_test',
turn: { id: 'turn_test', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Final response');
expect(result.sessionId).toBe('thr_test');
expect(result.tokenUsage).toEqual({
prompt: 10,
completion: 5,
total: 15,
cached: 2,
});
expect(result.cost).toBeCloseTo(0.000191);
expect(result.metadata?.codexAppServer).toMatchObject({
threadId: 'thr_test',
turnId: 'turn_test',
model: 'gpt-5.5',
sandboxMode: 'read-only',
approvalPolicy: 'never',
itemCounts: { agentMessage: 1 },
notificationCount: 5,
});
expect(JSON.parse(result.raw as string)).toMatchObject({
output: 'Final response',
tokenUsage: {
last: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 5,
},
},
});
expect(mocks.spawn).toHaveBeenCalledWith(
'/usr/local/bin/codex',
['app-server', '--listen', 'stdio://'],
expect.objectContaining({
env: expect.objectContaining({
OPENAI_API_KEY: 'test-api-key',
CODEX_API_KEY: 'test-api-key',
}),
stdio: ['pipe', 'pipe', 'pipe'],
}),
);
});
it('emits a protocol turn span with nested usage when no turn/started notification arrives', async () => {
const spans = installSpanRecorder();
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Trace this app-server turn');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_tracing' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_tracing', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_tracing',
turnId: 'turn_tracing',
item: { type: 'agentMessage', id: 'msg_tracing', text: 'Done' },
},
});
server.send({
method: 'thread/tokenUsage/updated',
params: {
threadId: 'thr_tracing',
turnId: 'turn_tracing',
tokenUsage: {
last: {
inputTokens: 13,
outputTokens: 5,
cachedInputTokens: 2,
reasoningOutputTokens: 1,
},
},
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_tracing',
turn: { id: 'turn_tracing', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Done' });
const turnSpan = spans.find((span) => span.name === 'gen_ai.turn 1');
expect(turnSpan?.attributes).toMatchObject({
'gen_ai.turn.index': 1,
'gen_ai.usage.input_tokens': 13,
'gen_ai.usage.output_tokens': 5,
'gen_ai.usage.cached_tokens': 2,
'gen_ai.usage.reasoning_tokens': 1,
});
expect(turnSpan?.ended).toBe(true);
const messageSpan = spans.find((span) => span.name === 'agent response');
expect(messageSpan?.attributes['gen_ai.turn.index']).toBe(1);
});
it('does not reuse usage from an earlier protocol turn marker', () => {
const spans = installSpanRecorder();
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const state = (provider as any).createTurnState('key', 'instance', 'thread', [], {}, {});
(provider as any).startTurnSpan(state, 1);
state.rawTokenUsage = { last: { inputTokens: 9, outputTokens: 4 } };
state.tokenUsage = { prompt: 9, completion: 4, total: 13 };
(provider as any).endTurnSpan(state, 2);
(provider as any).startTurnSpan(state, 3, true);
(provider as any).endTurnSpan(state, 4);
const turnSpans = spans.filter((span) => span.name.startsWith('gen_ai.turn '));
expect(turnSpans).toHaveLength(2);
expect(turnSpans[0].attributes).toMatchObject({
'gen_ai.usage.input_tokens': 9,
'gen_ai.usage.output_tokens': 4,
});
expect(turnSpans[1].attributes).not.toHaveProperty('gen_ai.usage.input_tokens');
expect(turnSpans[1].attributes).not.toHaveProperty('gen_ai.usage.output_tokens');
expect(state.tokenUsage).toBeUndefined();
});
it('preserves first-turn usage that arrived before the first turn/started', () => {
const spans = installSpanRecorder();
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const state = (provider as any).createTurnState('key', 'instance', 'thread', [], {}, {});
// Usage can arrive via `thread/tokenUsage/updated` before the first
// `turn/started` notification. That usage belongs to the first turn.
state.rawTokenUsage = { last: { inputTokens: 11, outputTokens: 7 } };
state.tokenUsage = { prompt: 11, completion: 7, total: 18 };
// The first `turn/started` passes clearPreviousUsage=true, but there is no
// previous turn yet (turnCount === 0), so the usage must survive.
(provider as any).startTurnSpan(state, 1, true);
expect(state.tokenUsage).toEqual({ prompt: 11, completion: 7, total: 18 });
expect(state.rawTokenUsage).toEqual({ last: { inputTokens: 11, outputTokens: 7 } });
(provider as any).endTurnSpan(state, 2);
const turnSpan = spans.find((span) => span.name === 'gen_ai.turn 1');
expect(turnSpan?.attributes).toMatchObject({
'gen_ai.usage.input_tokens': 11,
'gen_ai.usage.output_tokens': 7,
});
});
it('reports token usage on the response when usage arrives before turn/started', async () => {
const spans = installSpanRecorder();
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Trace usage ordering');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_order' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_order', status: 'inProgress' } },
});
// Usage arrives BEFORE the turn/started notification. A naive
// clearPreviousUsage would drop these counts from the final response.
server.send({
method: 'thread/tokenUsage/updated',
params: {
threadId: 'thr_order',
turnId: 'turn_order',
tokenUsage: {
last: {
inputTokens: 21,
outputTokens: 8,
cachedInputTokens: 3,
reasoningOutputTokens: 2,
},
},
},
});
server.send({
method: 'turn/started',
params: {
threadId: 'thr_order',
turn: { id: 'turn_order', status: 'inProgress' },
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_order',
turnId: 'turn_order',
item: { type: 'agentMessage', id: 'msg_order', text: 'Done' },
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_order',
turn: { id: 'turn_order', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result).toMatchObject({
output: 'Done',
tokenUsage: { prompt: 21, completion: 8 },
});
const turnSpan = spans.find((span) => span.name === 'gen_ai.turn 1');
expect(turnSpan?.attributes).toMatchObject({
'gen_ai.usage.input_tokens': 21,
'gen_ai.usage.output_tokens': 8,
});
});
it('marks a force-closed turn span ERROR when a new turn starts before the prior ends', () => {
const spans = installSpanRecorder();
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const state = (provider as any).createTurnState('key', 'instance', 'thread', [], {}, {});
(provider as any).startTurnSpan(state, 1);
// A second turn opens before the first was ended: the prior span must be
// force-closed with ERROR status rather than left dangling/UNSET.
(provider as any).startTurnSpan(state, 2);
(provider as any).endTurnSpan(state, 3);
const turnSpans = spans.filter((span) => span.name.startsWith('gen_ai.turn '));
expect(turnSpans).toHaveLength(2);
// SpanStatusCode.ERROR === 2
expect(turnSpans[0].status?.code).toBe(2);
expect(turnSpans[0].status?.message).toContain('not properly closed');
});
it('uses the last completed agent message as the final output', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Return structured output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_multi_message' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_multi_message', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_multi_message',
turnId: 'turn_multi_message',
item: {
type: 'agentMessage',
id: 'msg_progress',
text: '{"comments":[],"summary":"Inspecting diff."}',
phase: null,
memoryCitation: null,
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_multi_message',
turnId: 'turn_multi_message',
item: {
type: 'agentMessage',
id: 'msg_final',
text: '{"comments":[],"summary":"No actionable findings."}',
phase: null,
memoryCitation: null,
},
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_multi_message',
turn: { id: 'turn_multi_message', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('{"comments":[],"summary":"No actionable findings."}');
});
it('responds deterministically to server approval requests and records metadata', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
skip_git_repo_check: true,
server_request_policy: {
command_execution: 'decline',
},
},
});
const resultPromise = provider.callApi('Try a command');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_approval' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_approval', status: 'inProgress' } },
});
server.send({
id: 99,
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thr_approval',
turnId: 'turn_approval',
itemId: 'cmd_1',
command: 'cat .env',
cwd: process.cwd(),
},
});
const approvalResponse = await waitForMessage(
server,
(message) => message.id === 99 && message.result,
);
expect(approvalResponse.result).toEqual({ decision: 'decline' });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_approval',
turnId: 'turn_approval',
itemId: 'msg_1',
delta: 'Declined',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_approval',
turn: { id: 'turn_approval', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Declined');
expect(result.metadata?.codexAppServer.serverRequests).toEqual([
{
id: 99,
method: 'item/commandExecution/requestApproval',
params: expect.objectContaining({
command: 'cat .env',
itemId: 'cmd_1',
}),
response: { decision: 'decline' },
},
]);
});
it('continues with env-based auth when older app-server binaries lack account/login/start', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
apiKey: 'legacy-api-key',
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Use legacy auth compatibility');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const loginStart = await waitForMessage(
server,
(message) => message.method === 'account/login/start',
);
server.send({
id: loginStart.id,
error: {
code: -32601,
message: 'account/login/start is unavailable on this binary',
},
});
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_legacy_auth' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_legacy_auth', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_legacy_auth',
turnId: 'turn_legacy_auth',
itemId: 'msg_legacy_auth',
delta: 'Legacy auth ok',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_legacy_auth',
turn: { id: 'turn_legacy_auth', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Legacy auth ok' });
});
it('does not leak an ambient OpenAI key to the CLI (env or login) when routing to a custom model_provider', async () => {
const restore = mockProcessEnv({ OPENAI_API_KEY: 'sk-ambient-unrelated' });
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
try {
const provider = new OpenAICodexAppServerProvider({
config: {
model: 'openai.gpt-5.5',
model_provider: 'amazon-bedrock',
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run on Bedrock');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
// No account/login/start should be issued for the ambient OpenAI key; the next request
// is thread/start (mirrors the no-key flow).
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_bedrock_noleak' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_bedrock_noleak', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_bedrock_noleak',
turnId: 'turn_bedrock_noleak',
itemId: 'msg_bedrock_noleak',
delta: 'On Bedrock',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_bedrock_noleak',
turn: { id: 'turn_bedrock_noleak', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'On Bedrock' });
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
expect(spawnEnv.OPENAI_API_KEY).toBeUndefined();
expect(spawnEnv.CODEX_API_KEY).toBeUndefined();
// No account/login/start was ever sent for the unrelated ambient key.
expect(
server.messages().some((message: any) => message.method === 'account/login/start'),
).toBe(false);
} finally {
restore();
}
});
it('still injects an explicit config.apiKey when routing to a custom model_provider', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
model: 'openai.gpt-5.5',
model_provider: 'amazon-bedrock',
apiKey: 'explicit-bedrock-key',
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run on Bedrock with explicit key');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const loginStart = await waitForMessage(
server,
(message) => message.method === 'account/login/start',
);
expect(loginStart.params).toEqual({ type: 'apiKey', apiKey: 'explicit-bedrock-key' });
server.send({ id: loginStart.id, result: { type: 'apiKey' } });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_bedrock_explicit' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_bedrock_explicit', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_bedrock_explicit',
turnId: 'turn_bedrock_explicit',
itemId: 'msg_bedrock_explicit',
delta: 'Explicit key ok',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_bedrock_explicit',
turn: { id: 'turn_bedrock_explicit', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Explicit key ok' });
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
expect(spawnEnv.OPENAI_API_KEY).toBe('explicit-bedrock-key');
expect(spawnEnv.CODEX_API_KEY).toBe('explicit-bedrock-key');
});
it('strips an inherited OpenAI/Codex key from the CLI env when inherit_process_env is set with a custom model_provider', async () => {
// Highest-risk path: with inherit_process_env the whole process env is spread into the CLI
// env, so an ambient OPENAI_API_KEY/CODEX_API_KEY would reach the Bedrock-routed agent shell
// unless the strip loop removes it. Mirrors openai-codex-sdk.test.ts's inherit-path test.
const restore = mockProcessEnv({
OPENAI_API_KEY: 'sk-process-unrelated',
CODEX_API_KEY: 'codex-process-unrelated',
});
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
try {
const provider = new OpenAICodexAppServerProvider({
config: {
model: 'openai.gpt-5.5',
model_provider: 'amazon-bedrock',
inherit_process_env: true,
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run on Bedrock with inherited env');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
// No login for the unrelated ambient key — straight to thread/start.
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_bedrock_inherit' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_bedrock_inherit', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_bedrock_inherit',
turnId: 'turn_bedrock_inherit',
itemId: 'msg_bedrock_inherit',
delta: 'Inherited env ok',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_bedrock_inherit',
turn: { id: 'turn_bedrock_inherit', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Inherited env ok' });
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
expect(spawnEnv.OPENAI_API_KEY).toBeUndefined();
expect(spawnEnv.CODEX_API_KEY).toBeUndefined();
expect(
server.messages().some((message: any) => message.method === 'account/login/start'),
).toBe(false);
} finally {
restore();
}
});
it('preserves an OPENAI_API_KEY supplied explicitly via cli_env under a custom model_provider', async () => {
// The strip loop must not delete a key the user placed in cli_env on purpose (the
// `!(key in cli_env)` exception). No config.apiKey -> no login, mirroring the no-leak flow.
const restore = mockProcessEnv({ OPENAI_API_KEY: undefined, CODEX_API_KEY: undefined });
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
try {
const provider = new OpenAICodexAppServerProvider({
config: {
model: 'openai.gpt-5.5',
model_provider: 'amazon-bedrock',
cli_env: { OPENAI_API_KEY: 'cli-env-key' },
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run on Bedrock with cli_env key');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_bedrock_clienv' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_bedrock_clienv', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_bedrock_clienv',
turnId: 'turn_bedrock_clienv',
itemId: 'msg_bedrock_clienv',
delta: 'cli_env key ok',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_bedrock_clienv',
turn: { id: 'turn_bedrock_clienv', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'cli_env key ok' });
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
// The cli_env-supplied key survives the custom-provider strip loop.
expect(spawnEnv.OPENAI_API_KEY).toBe('cli-env-key');
} finally {
restore();
}
});
it('maps legacy approval requests by conversation id and legacy decision names', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
server_request_policy: {
command_execution: 'acceptForSession',
file_change: 'cancel',
},
},
});
const resultPromise = provider.callApi('Exercise legacy approval callbacks');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_legacy' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_legacy', status: 'inProgress' } },
});
server.send({
id: 401,
method: 'execCommandApproval',
params: {
conversationId: 'thr_legacy',
callId: 'legacy_cmd',
command: ['npm', 'test'],
cwd: process.cwd(),
},
});
const commandApproval = await waitForMessage(
server,
(message) => message.id === 401 && message.result,
);
expect(commandApproval.result).toEqual({ decision: 'approved_for_session' });
server.send({
id: 402,
method: 'applyPatchApproval',
params: {
conversationId: 'thr_legacy',
callId: 'legacy_patch',
changes: { files: ['src/index.ts'] },
},
});
const patchApproval = await waitForMessage(
server,
(message) => message.id === 402 && message.result,
);
expect(patchApproval.result).toEqual({ decision: 'abort' });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_legacy',
turnId: 'turn_legacy',
itemId: 'msg_legacy',
delta: 'Legacy handled',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_legacy',
turn: { id: 'turn_legacy', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Legacy handled');
expect(result.metadata?.codexAppServer.serverRequests).toEqual([
expect.objectContaining({
id: 401,
method: 'execCommandApproval',
response: { decision: 'approved_for_session' },
}),
expect.objectContaining({
id: 402,
method: 'applyPatchApproval',
response: { decision: 'abort' },
}),
]);
});
it('passes advanced approval, permission, and elicitation policy payloads', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const granularApprovalPolicy = {
granular: {
sandbox_approval: true,
rules: true,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
},
};
const commandDecision = {
applyNetworkPolicyAmendment: {
network_policy_amendment: {
host: 'registry.npmjs.org',
action: 'allow' as const,
},
},
};
const permissionGrant = {
network: { enabled: true },
fileSystem: {
read: ['/tmp/promptfoo-fixture'],
write: null,
},
};
const elicitationResponse = {
action: 'accept' as const,
content: { project: 'promptfoo', severity: 'low' },
_meta: { source: 'promptfoo-test' },
};
const provider = new OpenAICodexAppServerProvider({
config: {
approval_policy: granularApprovalPolicy,
thread_cleanup: 'none',
server_request_policy: {
command_execution: commandDecision,
permissions: {
permissions: permissionGrant,
scope: 'session',
},
mcp_elicitation: elicitationResponse,
},
},
});
const resultPromise = provider.callApi('Exercise advanced server request policies');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(threadStart.params.approvalPolicy).toEqual(granularApprovalPolicy);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_advanced_policy' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params.approvalPolicy).toEqual(granularApprovalPolicy);
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_advanced_policy', status: 'inProgress' } },
});
server.send({
id: 501,
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thr_advanced_policy',
turnId: 'turn_advanced_policy',
itemId: 'cmd_advanced_policy',
command: 'npm view promptfoo version',
},
});
const commandApproval = await waitForMessage(
server,
(message) => message.id === 501 && message.result,
);
expect(commandApproval.result).toEqual({ decision: commandDecision });
server.send({
id: 502,
method: 'item/permissions/requestApproval',
params: {
threadId: 'thr_advanced_policy',
turnId: 'turn_advanced_policy',
itemId: 'perm_advanced_policy',
reason: 'Needs fixture access',
permissions: {
network: { enabled: true },
fileSystem: { read: ['/tmp/promptfoo-fixture'], write: null },
},
},
});
const permissionsApproval = await waitForMessage(
server,
(message) => message.id === 502 && message.result,
);
expect(permissionsApproval.result).toEqual({
permissions: permissionGrant,
scope: 'session',
});
server.send({
id: 503,
method: 'mcpServer/elicitation/request',
params: {
threadId: 'thr_advanced_policy',
turnId: 'turn_advanced_policy',
serverName: 'forms',
mode: 'form',
_meta: null,
message: 'Provide project metadata',
requestedSchema: {
type: 'object',
properties: {
project: { type: 'string' },
},
},
},
});
const elicitation = await waitForMessage(
server,
(message) => message.id === 503 && message.result,
);
expect(elicitation.result).toEqual(elicitationResponse);
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_advanced_policy',
turnId: 'turn_advanced_policy',
itemId: 'msg_advanced_policy',
delta: 'Advanced handled',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_advanced_policy',
turn: { id: 'turn_advanced_policy', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Advanced handled');
expect(result.metadata?.codexAppServer.serverRequests).toEqual([
expect.objectContaining({
id: 501,
method: 'item/commandExecution/requestApproval',
response: { decision: commandDecision },
}),
expect.objectContaining({
id: 502,
method: 'item/permissions/requestApproval',
response: { permissions: permissionGrant, scope: 'session' },
}),
expect.objectContaining({
id: 503,
method: 'mcpServer/elicitation/request',
response: elicitationResponse,
}),
]);
});
it('uses thread/resume and normalizes structured prompt input items', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_existing',
service_tier: 'flex',
persist_threads: true,
thread_cleanup: 'none',
},
});
const prompt = JSON.stringify([
{ type: 'text', text: 'Review this screenshot' },
{ type: 'local_image', path: '/tmp/screenshot.png' },
{ type: 'skill', name: 'skill-creator', path: '/tmp/skill-creator/SKILL.md' },
]);
const resultPromise = provider.callApi(prompt);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
expect(threadResume.params).toMatchObject({
threadId: 'thr_existing',
approvalPolicy: 'never',
sandbox: 'read-only',
serviceTier: 'flex',
});
server.send({ id: threadResume.id, result: { thread: { id: 'thr_existing' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params.serviceTier).toBe('flex');
expect(turnStart.params.input).toEqual([
{ type: 'text', text: 'Review this screenshot', text_elements: [] },
{ type: 'localImage', path: '/tmp/screenshot.png' },
{ type: 'skill', name: 'skill-creator', path: '/tmp/skill-creator/SKILL.md' },
]);
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_existing', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing',
turnId: 'turn_existing',
itemId: 'msg_existing',
delta: 'Reviewed',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing',
turn: { id: 'turn_existing', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Reviewed');
expect(server.messages().some((message) => message.method === 'thread/unsubscribe')).toBe(
false,
);
});
it('unsubscribes a non-persistent resumed thread by default', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_existing_unsubscribe',
},
});
const resultPromise = provider.callApi('Resume and release this thread');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
server.send({
id: threadResume.id,
result: { thread: { id: 'thr_existing_unsubscribe' } },
});
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_existing_unsubscribe', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_unsubscribe',
turnId: 'turn_existing_unsubscribe',
itemId: 'msg_existing_unsubscribe',
delta: 'Released',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_unsubscribe',
turn: { id: 'turn_existing_unsubscribe', status: 'completed', items: [], error: null },
},
});
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_existing_unsubscribe',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
await expect(resultPromise).resolves.toMatchObject({ output: 'Released' });
});
it('defers resumed thread unsubscribe while another turn is queued for the same thread id', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_existing_queue',
},
});
const firstResultPromise = provider.callApi('Shared thread first');
const secondResultPromise = provider.callApi('Shared thread second');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
const secondThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume' && message.id !== firstThreadResume.id,
);
server.send({ id: firstThreadResume.id, result: { thread: { id: 'thr_existing_queue' } } });
server.send({ id: secondThreadResume.id, result: { thread: { id: 'thr_existing_queue' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_existing_queue_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_queue',
turnId: 'turn_existing_queue_1',
itemId: 'msg_existing_queue_1',
delta: 'Queued first',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_queue',
turn: { id: 'turn_existing_queue_1', status: 'completed', items: [], error: null },
},
});
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
const secondTurnStartIndex = server
.messages()
.findIndex((message) => message.method === 'turn/start' && message.id === secondTurnStart.id);
expect(
server
.messages()
.slice(0, secondTurnStartIndex)
.some((message) => message.method === 'thread/unsubscribe'),
).toBe(false);
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_existing_queue_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_queue',
turnId: 'turn_existing_queue_2',
itemId: 'msg_existing_queue_2',
delta: 'Queued second',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_queue',
turn: { id: 'turn_existing_queue_2', status: 'completed', items: [], error: null },
},
});
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_existing_queue',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Queued first' });
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Queued second' });
});
it('cleans up a resumed thread when a queued turn aborts before it starts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_existing_abort_queue',
},
});
const originalCleanupThreadAfterTurn = (provider as any).cleanupThreadAfterTurn.bind(provider);
let cleanupAttemptCount = 0;
vi.spyOn(provider as any, 'cleanupThreadAfterTurn').mockImplementation(
async (...args: any[]) => {
const result = await originalCleanupThreadAfterTurn(...args);
cleanupAttemptCount += 1;
if (cleanupAttemptCount === 1) {
abortController.abort();
}
return result;
},
);
const firstResultPromise = provider.callApi('Shared thread first');
const secondResultPromise = provider.callApi('Shared thread second', undefined, {
abortSignal: abortController.signal,
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
const secondThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume' && message.id !== firstThreadResume.id,
);
server.send({
id: firstThreadResume.id,
result: { thread: { id: 'thr_existing_abort_queue' } },
});
server.send({
id: secondThreadResume.id,
result: { thread: { id: 'thr_existing_abort_queue' } },
});
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_existing_abort_queue_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_abort_queue',
turnId: 'turn_existing_abort_queue_1',
itemId: 'msg_existing_abort_queue_1',
delta: 'Queued cleanup first',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_abort_queue',
turn: { id: 'turn_existing_abort_queue_1', status: 'completed', items: [], error: null },
},
});
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_existing_abort_queue',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Queued cleanup first' });
await expect(secondResultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(
server
.messages()
.filter(
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_existing_abort_queue',
),
).toHaveLength(1);
});
it('serializes deep-tracing turns that resume the same thread id', async () => {
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
deep_tracing: true,
thread_id: 'thr_existing_deep',
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Deep tracing shared first');
const secondResultPromise = provider.callApi('Deep tracing shared second');
const firstInitialize = await waitForMessage(
firstServer,
(message) => message.method === 'initialize',
);
const secondInitialize = await waitForMessage(
secondServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
secondServer.send({ id: secondInitialize.id, result: {} });
const firstThreadResume = await waitForMessage(
firstServer,
(message) => message.method === 'thread/resume',
);
firstServer.send({
id: firstThreadResume.id,
result: { thread: { id: 'thr_existing_deep' } },
});
const firstTurnStart = await waitForMessage(
firstServer,
(message) => message.method === 'turn/start',
);
firstServer.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_existing_deep_1', status: 'inProgress' } },
});
const secondThreadResume = await waitForMessage(
secondServer,
(message) => message.method === 'thread/resume',
);
secondServer.send({
id: secondThreadResume.id,
result: { thread: { id: 'thr_existing_deep' } },
});
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(secondServer.messages().some((message) => message.method === 'turn/start')).toBe(false);
firstServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_deep',
turnId: 'turn_existing_deep_1',
itemId: 'msg_existing_deep_1',
delta: 'Deep first',
},
});
firstServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_deep',
turn: { id: 'turn_existing_deep_1', status: 'completed', items: [], error: null },
},
});
const secondTurnStart = await waitForMessage(
secondServer,
(message) => message.method === 'turn/start',
);
secondServer.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_existing_deep_2', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_deep',
turnId: 'turn_existing_deep_2',
itemId: 'msg_existing_deep_2',
delta: 'Deep second',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_deep',
turn: { id: 'turn_existing_deep_2', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Deep first' });
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Deep second' });
});
it('ignores late notifications from completed turn ids on shared threads', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_late_events',
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Late event first');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
server.send({ id: firstThreadResume.id, result: { thread: { id: 'thr_late_events' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_late_events_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_late_events',
turnId: 'turn_late_events_1',
itemId: 'msg_late_events_1',
delta: 'First',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_late_events',
turn: { id: 'turn_late_events_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First' });
const secondResultPromise = provider.callApi('Late event second');
const secondThreadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume' && message.id !== firstThreadResume.id,
);
server.send({ id: secondThreadResume.id, result: { thread: { id: 'thr_late_events' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_late_events_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_late_events',
turnId: 'turn_late_events_1',
itemId: 'msg_late_events_1',
delta: 'Late old event',
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_late_events',
turnId: 'turn_late_events_2',
itemId: 'msg_late_events_2',
delta: 'Second',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_late_events',
turn: { id: 'turn_late_events_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second' });
});
it('unsubscribes non-persistent threads by default', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider();
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_unsubscribe' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_unsubscribe', status: 'inProgress' } },
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_unsubscribe',
turn: { id: 'turn_unsubscribe', status: 'completed', items: [], error: null },
},
});
const unsubscribe = await waitForMessage(
server,
(message) => message.method === 'thread/unsubscribe',
);
expect(unsubscribe.params).toEqual({ threadId: 'thr_unsubscribe' });
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
const result = await resultPromise;
expect(result.sessionId).toBe('thr_unsubscribe');
});
it('unsubscribes evicted persistent threads when the thread pool is full', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_pool_size: 1,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('First cached prompt');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_pool_1' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_pool_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_pool_1',
turnId: 'turn_pool_1',
itemId: 'msg_pool_1',
delta: 'First cached',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_pool_1',
turn: { id: 'turn_pool_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First cached' });
const secondResultPromise = provider.callApi('Second cached prompt');
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' && message.params?.threadId === 'thr_pool_1',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
const secondThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
server.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_pool_2' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_pool_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_pool_2',
turnId: 'turn_pool_2',
itemId: 'msg_pool_2',
delta: 'Second cached',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_pool_2',
turn: { id: 'turn_pool_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second cached' });
});
it('drops cached persistent threads when a reused app-server process exits', async () => {
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Reusable cached prompt');
const firstInitialize = await waitForMessage(
firstServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
const firstThreadStart = await waitForMessage(
firstServer,
(message) => message.method === 'thread/start',
);
firstServer.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_stale_1' } } });
const firstTurnStart = await waitForMessage(
firstServer,
(message) => message.method === 'turn/start',
);
firstServer.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_stale_1', status: 'inProgress' } },
});
firstServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_stale_1',
turnId: 'turn_stale_1',
itemId: 'msg_stale_1',
delta: 'First reusable',
},
});
firstServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_stale_1',
turn: { id: 'turn_stale_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First reusable' });
firstServer.proc.emit('exit', 0, null);
const secondResultPromise = provider.callApi('Reusable cached prompt');
const secondInitialize = await waitForMessage(
secondServer,
(message) => message.method === 'initialize',
);
secondServer.send({ id: secondInitialize.id, result: {} });
const secondThreadStart = await waitForMessage(
secondServer,
(message) => message.method === 'thread/start',
);
expect(secondThreadStart.params).toMatchObject({ ephemeral: true });
secondServer.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_stale_2' } } });
const secondTurnStart = await waitForMessage(
secondServer,
(message) => message.method === 'turn/start',
);
expect(secondTurnStart.params.threadId).toBe('thr_stale_2');
secondServer.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_stale_2', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_stale_2',
turnId: 'turn_stale_2',
itemId: 'msg_stale_2',
delta: 'Second reusable',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_stale_2',
turn: { id: 'turn_stale_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second reusable' });
expect(mocks.spawn).toHaveBeenCalledTimes(2);
});
it('purges pending persistent thread promises when a reused app-server process exits', async () => {
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Pending persistent thread');
const firstInitialize = await waitForMessage(
firstServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
await waitForMessage(firstServer, (message) => message.method === 'thread/start');
expect((provider as any).threadPromises.size).toBe(1);
firstServer.proc.exitCode = 1;
firstServer.proc.emit('exit', 1, null);
expect((provider as any).threadPromises.size).toBe(0);
const secondResultPromise = provider.callApi('Pending persistent thread');
await expect(firstResultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server exited with code 1 signal null',
});
const secondInitialize = await waitForMessage(
secondServer,
(message) => message.method === 'initialize',
);
secondServer.send({ id: secondInitialize.id, result: {} });
const secondThreadStart = await waitForMessage(
secondServer,
(message) => message.method === 'thread/start',
);
secondServer.send({
id: secondThreadStart.id,
result: { thread: { id: 'thr_pending_recovered' } },
});
const secondTurnStart = await waitForMessage(
secondServer,
(message) => message.method === 'turn/start',
);
secondServer.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_pending_recovered', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_pending_recovered',
turnId: 'turn_pending_recovered',
itemId: 'msg_pending_recovered',
delta: 'Recovered persistent',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_pending_recovered',
turn: { id: 'turn_pending_recovered', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Recovered persistent' });
expect(mocks.spawn).toHaveBeenCalledTimes(2);
});
it('does not cache persistent threads for non-reusable app-server processes', async () => {
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
reuse_server: false,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Fresh process prompt');
const firstInitialize = await waitForMessage(
firstServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
const firstThreadStart = await waitForMessage(
firstServer,
(message) => message.method === 'thread/start',
);
firstServer.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_fresh_1' } } });
const firstTurnStart = await waitForMessage(
firstServer,
(message) => message.method === 'turn/start',
);
firstServer.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_fresh_1', status: 'inProgress' } },
});
firstServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_fresh_1',
turnId: 'turn_fresh_1',
itemId: 'msg_fresh_1',
delta: 'First fresh',
},
});
firstServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_fresh_1',
turn: { id: 'turn_fresh_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First fresh' });
const secondResultPromise = provider.callApi('Fresh process prompt');
const secondInitialize = await waitForMessage(
secondServer,
(message) => message.method === 'initialize',
);
secondServer.send({ id: secondInitialize.id, result: {} });
const secondThreadStart = await waitForMessage(
secondServer,
(message) => message.method === 'thread/start',
);
secondServer.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_fresh_2' } } });
const secondTurnStart = await waitForMessage(
secondServer,
(message) => message.method === 'turn/start',
);
expect(secondTurnStart.params.threadId).toBe('thr_fresh_2');
secondServer.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_fresh_2', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_fresh_2',
turnId: 'turn_fresh_2',
itemId: 'msg_fresh_2',
delta: 'Second fresh',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_fresh_2',
turn: { id: 'turn_fresh_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second fresh' });
});
it('does not evict a cached persistent thread while its turn is active', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_pool_size: 1,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Active cached prompt one');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_active_1' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_active_1', status: 'inProgress' } },
});
const secondResultPromise = provider.callApi('Active cached prompt two');
const secondThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
expect(
server
.messages()
.some(
(message) =>
message.method === 'thread/unsubscribe' && message.params?.threadId === 'thr_active_1',
),
).toBe(false);
server.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_active_2' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_active_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_active_2',
turnId: 'turn_active_2',
itemId: 'msg_active_2',
delta: 'Second active',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_active_2',
turn: { id: 'turn_active_2', status: 'completed', items: [], error: null },
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_active_1',
turnId: 'turn_active_1',
itemId: 'msg_active_1',
delta: 'First active',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_active_1',
turn: { id: 'turn_active_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First active' });
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second active' });
});
it('rebalances the persistent thread pool after concurrent active turns finish', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_pool_size: 1,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Concurrent cached prompt one');
const secondResultPromise = provider.callApi('Concurrent cached prompt two');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
const secondThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
server.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_concurrent_1' } } });
server.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_concurrent_2' } } });
const firstTurnStart = await waitForMessage(
server,
(message) =>
message.method === 'turn/start' && message.params?.threadId === 'thr_concurrent_1',
);
const secondTurnStart = await waitForMessage(
server,
(message) =>
message.method === 'turn/start' && message.params?.threadId === 'thr_concurrent_2',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_concurrent_1', status: 'inProgress' } },
});
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_concurrent_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_concurrent_2',
turnId: 'turn_concurrent_2',
itemId: 'msg_concurrent_2',
delta: 'Concurrent second',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_concurrent_2',
turn: { id: 'turn_concurrent_2', status: 'completed', items: [], error: null },
},
});
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' && message.params?.threadId === 'thr_concurrent_2',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_concurrent_1',
turnId: 'turn_concurrent_1',
itemId: 'msg_concurrent_1',
delta: 'Concurrent first',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_concurrent_1',
turn: { id: 'turn_concurrent_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Concurrent first' });
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Concurrent second' });
});
it('shares an in-flight persistent thread start for concurrent calls with the same cache key', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Shared cached prompt');
const secondResultPromise = provider.callApi('Shared cached prompt');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(server.messages().filter((message) => message.method === 'thread/start')).toHaveLength(
1,
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_shared' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
expect(firstTurnStart.params.threadId).toBe('thr_shared');
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_shared_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_shared',
turnId: 'turn_shared_1',
itemId: 'msg_shared_1',
delta: 'Shared first',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_shared',
turn: { id: 'turn_shared_1', status: 'completed', items: [], error: null },
},
});
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
expect(secondTurnStart.params.threadId).toBe('thr_shared');
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_shared_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_shared',
turnId: 'turn_shared_2',
itemId: 'msg_shared_2',
delta: 'Shared second',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_shared',
turn: { id: 'turn_shared_2', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Shared first' });
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Shared second' });
expect(server.messages().filter((message) => message.method === 'thread/start')).toHaveLength(
1,
);
});
it('keeps shared persistent thread start alive when the first waiter aborts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Shared aborted thread start', undefined, {
abortSignal: abortController.signal,
} as any);
const secondResultPromise = provider.callApi('Shared aborted thread start');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(server.messages().filter((message) => message.method === 'thread/start')).toHaveLength(
1,
);
abortController.abort();
await expect(firstResultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({ id: threadStart.id, result: { thread: { id: 'thr_shared_abort_start' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
expect(secondTurnStart.params.threadId).toBe('thr_shared_abort_start');
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_shared_abort_start_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_shared_abort_start',
turnId: 'turn_shared_abort_start_2',
itemId: 'msg_shared_abort_start_2',
delta: 'Second survived thread start abort',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_shared_abort_start',
turn: { id: 'turn_shared_abort_start_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({
output: 'Second survived thread start abort',
});
const thirdResultPromise = provider.callApi('Shared aborted thread start');
const thirdTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== secondTurnStart.id,
);
expect(thirdTurnStart.params.threadId).toBe('thr_shared_abort_start');
expect(server.messages().filter((message) => message.method === 'thread/start')).toHaveLength(
1,
);
server.send({
id: thirdTurnStart.id,
result: { turn: { id: 'turn_shared_abort_start_3', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_shared_abort_start',
turnId: 'turn_shared_abort_start_3',
itemId: 'msg_shared_abort_start_3',
delta: 'Cached after abort',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_shared_abort_start',
turn: { id: 'turn_shared_abort_start_3', status: 'completed', items: [], error: null },
},
});
await expect(thirdResultPromise).resolves.toMatchObject({ output: 'Cached after abort' });
expect(mocks.spawn).toHaveBeenCalledTimes(1);
});
it('keeps shared persistent thread resume alive when the first waiter aborts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
thread_id: 'thr_existing_shared_abort',
persist_threads: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Shared aborted thread resume first', undefined, {
abortSignal: abortController.signal,
} as any);
const secondResultPromise = provider.callApi('Shared aborted thread resume second');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
expect(server.messages().filter((message) => message.method === 'thread/resume')).toHaveLength(
1,
);
abortController.abort();
await expect(firstResultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({
id: threadResume.id,
result: { thread: { id: 'thr_existing_shared_abort' } },
});
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
expect(secondTurnStart.params.threadId).toBe('thr_existing_shared_abort');
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_shared_abort_resume_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_existing_shared_abort',
turnId: 'turn_shared_abort_resume_2',
itemId: 'msg_shared_abort_resume_2',
delta: 'Second survived thread resume abort',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_existing_shared_abort',
turn: { id: 'turn_shared_abort_resume_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({
output: 'Second survived thread resume abort',
});
expect(server.messages().filter((message) => message.method === 'thread/resume')).toHaveLength(
1,
);
expect(mocks.spawn).toHaveBeenCalledTimes(1);
});
it('includes thread-start options in the persistent thread cache key', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
persist_threads: true,
thread_pool_size: 2,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Thread options prompt', {
prompt: {
raw: 'Thread options prompt',
config: { ephemeral: true },
},
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(firstThreadStart.params.ephemeral).toBe(true);
server.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_options_1' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_options_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_options_1',
turnId: 'turn_options_1',
itemId: 'msg_options_1',
delta: 'Options one',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_options_1',
turn: { id: 'turn_options_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'Options one' });
const secondResultPromise = provider.callApi('Thread options prompt', {
prompt: {
raw: 'Thread options prompt',
config: { ephemeral: false },
},
} as any);
const secondThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
expect(secondThreadStart.params.ephemeral).toBe(false);
server.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_options_2' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
expect(secondTurnStart.params.threadId).toBe('thr_options_2');
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_options_2', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_options_2',
turnId: 'turn_options_2',
itemId: 'msg_options_2',
delta: 'Options two',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_options_2',
turn: { id: 'turn_options_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Options two' });
});
it('answers user input and dynamic tool server requests from policy', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
server_request_policy: {
user_input: 'first-option',
dynamic_tools: {
classify: {
success: true,
text: '{"label":"safe"}',
},
},
},
},
});
const resultPromise = provider.callApi('Ask for input');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_tools' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({ id: turnStart.id, result: { turn: { id: 'turn_tools', status: 'inProgress' } } });
server.send({
id: 100,
method: 'item/tool/requestUserInput',
params: {
threadId: 'thr_tools',
turnId: 'turn_tools',
itemId: 'question_1',
questions: [
{
id: 'severity',
header: 'Severity',
question: 'Choose severity',
isOther: false,
isSecret: false,
options: [{ label: 'high', description: 'High impact' }],
},
],
},
});
const userInputResponse = await waitForMessage(
server,
(message) => message.id === 100 && message.result,
);
expect(userInputResponse.result).toEqual({
answers: {
severity: { answers: ['high'] },
},
});
server.send({
id: 101,
method: 'item/tool/call',
params: {
threadId: 'thr_tools',
turnId: 'turn_tools',
callId: 'call_1',
tool: 'classify',
arguments: { text: 'hello' },
},
});
const dynamicToolResponse = await waitForMessage(
server,
(message) => message.id === 101 && message.result,
);
expect(dynamicToolResponse.result).toEqual({
contentItems: [{ type: 'inputText', text: '{"label":"safe"}' }],
success: true,
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_tools',
turnId: 'turn_tools',
itemId: 'msg_tools',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_tools',
turn: { id: 'turn_tools', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.output).toBe('Done');
expect(result.metadata?.codexAppServer.serverRequests).toHaveLength(2);
});
it('interrupts an active turn when the turn timeout fires', async () => {
vi.useFakeTimers();
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
turn_timeout_ms: 1,
},
});
try {
const resultPromise = provider.callApi('This will time out');
const initialize = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'initialize',
);
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_timeout' } } });
const turnStart = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_timeout', status: 'inProgress' } },
});
await flushMicrotasks();
await vi.advanceTimersByTimeAsync(1);
const interrupt = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'turn/interrupt',
);
expect(interrupt.params).toEqual({
threadId: 'thr_timeout',
turnId: 'turn_timeout',
});
server.send({ id: interrupt.id, result: {} });
await expect(resultPromise).resolves.toEqual({
error: 'Error calling OpenAI Codex app-server: codex app-server turn timed out after 1ms',
});
} finally {
vi.useRealTimers();
}
});
it('removes abort listeners when a JSON-RPC request timeout fires', async () => {
vi.useFakeTimers();
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener');
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1,
thread_cleanup: 'none',
},
});
try {
const resultPromise = provider.callApi('Timeout thread start', undefined, {
abortSignal: abortController.signal,
} as any);
const initialize = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'initialize',
);
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_timeout_listener' } } });
await waitForMessageWithoutTimers(server, (message) => message.method === 'turn/start');
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server request timed out: turn/start',
});
expect(removeAbortListener).toHaveBeenCalledWith('abort', expect.any(Function));
expect(server.proc.kill).toHaveBeenCalledWith('SIGTERM');
} finally {
vi.useRealTimers();
}
});
it('restarts a reused app-server process after a JSON-RPC request timeout', async () => {
vi.useFakeTimers();
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1,
thread_cleanup: 'none',
},
});
try {
const timedOutResultPromise = provider.callApi('Timeout before thread start completes');
const firstInitialize = await waitForMessageWithoutTimers(
firstServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
await waitForMessageWithoutTimers(
firstServer,
(message) => message.method === 'thread/start',
);
await vi.advanceTimersByTimeAsync(1);
await expect(timedOutResultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server request timed out: thread/start',
});
expect(firstServer.proc.kill).toHaveBeenCalledWith('SIGTERM');
const recoveredResultPromise = provider.callApi('Recover after request timeout', {
prompt: {
raw: 'Recover after request timeout',
config: {
request_timeout_ms: 1_000,
},
},
} as any);
const secondInitialize = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'initialize',
);
secondServer.send({ id: secondInitialize.id, result: {} });
const threadStart = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'thread/start',
);
secondServer.send({ id: threadStart.id, result: { thread: { id: 'thr_after_timeout' } } });
const turnStart = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'turn/start',
);
secondServer.send({
id: turnStart.id,
result: { turn: { id: 'turn_after_timeout', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_after_timeout',
turnId: 'turn_after_timeout',
itemId: 'msg_after_timeout',
delta: 'Recovered after timeout',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_after_timeout',
turn: { id: 'turn_after_timeout', status: 'completed', items: [], error: null },
},
});
await expect(recoveredResultPromise).resolves.toMatchObject({
output: 'Recovered after timeout',
});
expect(mocks.spawn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('does not fail an active turn when another reused JSON-RPC request aborts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1_000,
thread_cleanup: 'none',
},
});
const activeResultPromise = provider.callApi('Active turn survives unrelated abort');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const activeThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: activeThreadStart.id, result: { thread: { id: 'thr_active_abort' } } });
const activeTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: activeTurnStart.id,
result: { turn: { id: 'turn_active_abort', status: 'inProgress' } },
});
const abortedResultPromise = provider.callApi('Abort unrelated request', undefined, {
abortSignal: abortController.signal,
} as any);
await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== activeThreadStart.id,
);
abortController.abort();
await expect(abortedResultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_active_abort',
turnId: 'turn_active_abort',
itemId: 'msg_active_abort',
delta: 'Active turn survived',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_active_abort',
turn: { id: 'turn_active_abort', status: 'completed', items: [], error: null },
},
});
await expect(activeResultPromise).resolves.toMatchObject({
output: 'Active turn survived',
});
expect(mocks.spawn).toHaveBeenCalledTimes(1);
expect(server.proc.kill).not.toHaveBeenCalled();
});
it('cleans up a late non-persistent thread start response after caller aborts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1_000,
},
});
const resultPromise = provider.callApi('Abort before thread start response', undefined, {
abortSignal: abortController.signal,
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
abortController.abort();
await expect(resultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({ id: threadStart.id, result: { thread: { id: 'thr_late_abort_start' } } });
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_late_abort_start',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
await flushMicrotasks();
expect(server.messages().some((message) => message.method === 'turn/start')).toBe(false);
expect(server.proc.kill).not.toHaveBeenCalled();
});
it('cleans up a late non-persistent thread resume response after caller aborts', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1_000,
thread_id: 'thr_existing_late_abort_resume',
},
});
const resultPromise = provider.callApi('Abort before thread resume response', undefined, {
abortSignal: abortController.signal,
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadResume = await waitForMessage(
server,
(message) => message.method === 'thread/resume',
);
abortController.abort();
await expect(resultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({
id: threadResume.id,
result: { thread: { id: 'thr_existing_late_abort_resume' } },
});
const unsubscribe = await waitForMessage(
server,
(message) =>
message.method === 'thread/unsubscribe' &&
message.params?.threadId === 'thr_existing_late_abort_resume',
);
server.send({ id: unsubscribe.id, result: { status: 'unsubscribed' } });
await flushMicrotasks();
expect(server.messages().some((message) => message.method === 'turn/start')).toBe(false);
expect(server.proc.kill).not.toHaveBeenCalled();
});
it('keeps a reused app-server process after a JSON-RPC request abort', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const abortController = new AbortController();
const provider = new OpenAICodexAppServerProvider({
config: {
request_timeout_ms: 1_000,
thread_cleanup: 'none',
},
});
const abortedResultPromise = provider.callApi(
'Abort before thread start completes',
undefined,
{
abortSignal: abortController.signal,
} as any,
);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const abortedThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
abortController.abort();
await expect(abortedResultPromise).resolves.toEqual({
error: 'OpenAI Codex app-server call aborted',
});
expect(server.proc.kill).not.toHaveBeenCalled();
server.send({
id: abortedThreadStart.id,
result: { thread: { id: 'thr_late_aborted_request' } },
});
const recoveredResultPromise = provider.callApi('Recover after request abort');
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== abortedThreadStart.id,
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_after_abort' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_after_abort', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_after_abort',
turnId: 'turn_after_abort',
itemId: 'msg_after_abort',
delta: 'Recovered after abort',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_after_abort',
turn: { id: 'turn_after_abort', status: 'completed', items: [], error: null },
},
});
await expect(recoveredResultPromise).resolves.toMatchObject({
output: 'Recovered after abort',
});
expect(mocks.spawn).toHaveBeenCalledTimes(1);
expect(server.proc.kill).not.toHaveBeenCalled();
});
it('cleans up failed startup attempts and retries with a fresh app-server process', async () => {
vi.useFakeTimers();
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
try {
const firstResultPromise = provider.callApi('First attempt', {
prompt: {
raw: 'First attempt',
config: {
startup_timeout_ms: 1,
},
},
} as any);
await waitForMessageWithoutTimers(firstServer, (message) => message.method === 'initialize');
await vi.advanceTimersByTimeAsync(1);
await expect(firstResultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server request timed out: initialize',
});
expect(firstServer.proc.kill).toHaveBeenCalledWith('SIGTERM');
const secondResultPromise = provider.callApi('Second attempt', {
prompt: {
raw: 'Second attempt',
config: {
startup_timeout_ms: 1_000,
},
},
} as any);
const initialize = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'initialize',
);
secondServer.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'thread/start',
);
secondServer.send({ id: threadStart.id, result: { thread: { id: 'thr_retry' } } });
const turnStart = await waitForMessageWithoutTimers(
secondServer,
(message) => message.method === 'turn/start',
);
secondServer.send({
id: turnStart.id,
result: { turn: { id: 'turn_retry', status: 'inProgress' } },
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_retry',
turnId: 'turn_retry',
itemId: 'msg_retry',
delta: 'Recovered',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_retry',
turn: { id: 'turn_retry', status: 'completed', items: [], error: null },
},
});
const result = await secondResultPromise;
expect(result.output).toBe('Recovered');
expect(mocks.spawn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('merges prompt-level nested config with provider defaults', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
cli_config: {
provider_only: true,
shared_config: 'provider',
},
cli_env: {
PROVIDER_ONLY: 'yes',
SHARED_ENV: 'provider',
},
server_request_policy: {
command_execution: 'decline',
file_change: 'decline',
permissions: {
permissions: { read: true },
scope: 'session',
strict_auto_review: true,
},
dynamic_tools: {
providerTool: {
text: 'provider tool result',
},
},
},
},
});
const resultPromise = provider.callApi('Merged nested config', {
prompt: {
raw: 'Merged nested config',
config: {
cli_config: {
prompt_only: true,
shared_config: 'prompt',
},
cli_env: {
PROMPT_ONLY: 'yes',
SHARED_ENV: 'prompt',
},
server_request_policy: { command_execution: 'accept' },
},
},
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
const spawnArgs = mocks.spawn.mock.calls[0][1] as string[];
expect(spawnArgs).toEqual(
expect.arrayContaining(['provider_only=true', 'prompt_only=true', 'shared_config="prompt"']),
);
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
expect(spawnEnv.PROVIDER_ONLY).toBe('yes');
expect(spawnEnv.PROMPT_ONLY).toBe('yes');
expect(spawnEnv.SHARED_ENV).toBe('prompt');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_nested_config' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_nested_config', status: 'inProgress' } },
});
server.send({
id: 351,
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thr_nested_config',
turnId: 'turn_nested_config',
itemId: 'cmd_nested_config',
command: 'npm test',
},
});
const commandApproval = await waitForMessage(
server,
(message) => message.id === 351 && message.result,
);
expect(commandApproval.result).toEqual({ decision: 'accept' });
server.send({
id: 352,
method: 'item/fileChange/requestApproval',
params: {
threadId: 'thr_nested_config',
turnId: 'turn_nested_config',
itemId: 'file_nested_config',
changes: { files: ['src/index.ts'] },
},
});
const fileApproval = await waitForMessage(
server,
(message) => message.id === 352 && message.result,
);
expect(fileApproval.result).toEqual({ decision: 'decline' });
server.send({
id: 353,
method: 'item/permissions/requestApproval',
params: {
threadId: 'thr_nested_config',
turnId: 'turn_nested_config',
itemId: 'perm_nested_config',
},
});
const permissionsApproval = await waitForMessage(
server,
(message) => message.id === 353 && message.result,
);
expect(permissionsApproval.result).toEqual({
permissions: { read: true },
scope: 'session',
strictAutoReview: true,
});
server.send({
id: 354,
method: 'item/tool/call',
params: {
threadId: 'thr_nested_config',
turnId: 'turn_nested_config',
callId: 'tool_nested_config',
tool: 'providerTool',
arguments: {},
},
});
const dynamicToolResponse = await waitForMessage(
server,
(message) => message.id === 354 && message.result,
);
expect(dynamicToolResponse.result).toEqual({
contentItems: [{ type: 'inputText', text: 'provider tool result' }],
success: true,
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_nested_config',
turnId: 'turn_nested_config',
itemId: 'msg_nested_config',
delta: 'Merged config done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_nested_config',
turn: { id: 'turn_nested_config', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Merged config done' });
});
it('renders prompt-level config templates with test vars before use', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Rendered config', {
vars: {
envValue: 'rendered-env',
modelName: 'gpt-5.4',
workspaceDir: process.cwd(),
},
prompt: {
raw: 'Rendered config',
config: {
working_dir: '{{ workspaceDir }}',
model: '{{ modelName }}',
cli_config: {
rendered_config: '{{ envValue }}',
},
cli_env: {
RENDERED_VALUE: '{{ envValue }}',
},
},
},
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
const spawnArgs = mocks.spawn.mock.calls[0][1] as string[];
expect(spawnArgs).toEqual(expect.arrayContaining(['rendered_config="rendered-env"']));
const spawnEnv = mocks.spawn.mock.calls[0][2].env as Record<string, string>;
expect(spawnEnv.RENDERED_VALUE).toBe('rendered-env');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
expect(threadStart.params).toMatchObject({
cwd: process.cwd(),
model: 'gpt-5.4',
config: {
rendered_config: 'rendered-env',
},
});
server.send({ id: threadStart.id, result: { thread: { id: 'thr_rendered_config' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params).toMatchObject({
cwd: process.cwd(),
model: 'gpt-5.4',
});
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_rendered_config', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_rendered_config',
turnId: 'turn_rendered_config',
itemId: 'msg_rendered_config',
delta: 'Rendered config done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_rendered_config',
turn: { id: 'turn_rendered_config', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Rendered config done' });
});
it('applies prompt-level server request policy for each turn on a reused connection', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
server_request_policy: {
command_execution: 'decline',
},
},
});
const firstResultPromise = provider.callApi('First turn', {
prompt: {
raw: 'First turn',
config: {
server_request_policy: { command_execution: 'accept' },
},
},
} as any);
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_policy_1' } } });
const firstTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_policy_1', status: 'inProgress' } },
});
server.send({
id: 301,
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thr_policy_1',
turnId: 'turn_policy_1',
itemId: 'cmd_policy_1',
command: 'npm test',
},
});
const firstApprovalResponse = await waitForMessage(
server,
(message) => message.id === 301 && message.result,
);
expect(firstApprovalResponse.result).toEqual({ decision: 'accept' });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_policy_1',
turnId: 'turn_policy_1',
itemId: 'msg_policy_1',
delta: 'First done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_policy_1',
turn: { id: 'turn_policy_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First done' });
const secondResultPromise = provider.callApi('Second turn', {
prompt: {
raw: 'Second turn',
config: {
server_request_policy: { command_execution: 'decline' },
},
},
} as any);
const secondThreadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
server.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_policy_2' } } });
const secondTurnStart = await waitForMessage(
server,
(message) => message.method === 'turn/start' && message.id !== firstTurnStart.id,
);
server.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_policy_2', status: 'inProgress' } },
});
server.send({
id: 302,
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thr_policy_2',
turnId: 'turn_policy_2',
itemId: 'cmd_policy_2',
command: 'cat .env',
},
});
const secondApprovalResponse = await waitForMessage(
server,
(message) => message.id === 302 && message.result,
);
expect(secondApprovalResponse.result).toEqual({ decision: 'decline' });
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_policy_2',
turnId: 'turn_policy_2',
itemId: 'msg_policy_2',
delta: 'Second done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_policy_2',
turn: { id: 'turn_policy_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second done' });
expect(mocks.spawn).toHaveBeenCalledTimes(1);
});
it('uses prompt-level request timeouts for thread requests on reused connections', async () => {
vi.useFakeTimers();
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
try {
const firstResultPromise = provider.callApi('Reusable timeout prompt one', {
prompt: {
raw: 'Reusable timeout prompt one',
config: { request_timeout_ms: 1_000 },
},
} as any);
const initialize = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'initialize',
);
server.send({ id: initialize.id, result: {} });
const firstThreadStart = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'thread/start',
);
server.send({
id: firstThreadStart.id,
result: { thread: { id: 'thr_timeout_config_1' } },
});
const firstTurnStart = await waitForMessageWithoutTimers(
server,
(message) => message.method === 'turn/start',
);
server.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_timeout_config_1', status: 'inProgress' } },
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_timeout_config_1',
turnId: 'turn_timeout_config_1',
itemId: 'msg_timeout_config_1',
delta: 'First timeout config',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_timeout_config_1',
turn: { id: 'turn_timeout_config_1', status: 'completed', items: [], error: null },
},
});
await expect(firstResultPromise).resolves.toMatchObject({ output: 'First timeout config' });
const secondResultPromise = provider.callApi('Reusable timeout prompt two', {
prompt: {
raw: 'Reusable timeout prompt two',
config: { request_timeout_ms: 17 },
},
} as any);
await waitForMessageWithoutTimers(
server,
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
);
expect(setTimeoutSpy.mock.calls.some(([, timeoutMs]) => timeoutMs === 17)).toBe(true);
await vi.advanceTimersByTimeAsync(17);
await expect(secondResultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server request timed out: thread/start',
});
expect(
server
.messages()
.filter(
(message) => message.method === 'thread/start' && message.id !== firstThreadStart.id,
),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it('fails an active turn when the app-server process exits before completion', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Crash mid-turn');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_crash' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_crash', status: 'inProgress' } },
});
server.proc.exitCode = 1;
server.proc.emit('exit', 1, null);
await expect(resultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server exited with code 1 signal null',
});
});
it('isolates process exits to the matching deep-tracing app-server process', async () => {
const firstServer = createMockAppServer();
const secondServer = createMockAppServer();
mocks.spawn.mockReturnValueOnce(firstServer.proc).mockReturnValueOnce(secondServer.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
deep_tracing: true,
thread_cleanup: 'none',
},
});
const firstResultPromise = provider.callApi('Deep tracing crash first');
const secondResultPromise = provider.callApi('Deep tracing crash second');
const firstInitialize = await waitForMessage(
firstServer,
(message) => message.method === 'initialize',
);
const secondInitialize = await waitForMessage(
secondServer,
(message) => message.method === 'initialize',
);
firstServer.send({ id: firstInitialize.id, result: {} });
secondServer.send({ id: secondInitialize.id, result: {} });
const firstThreadStart = await waitForMessage(
firstServer,
(message) => message.method === 'thread/start',
);
const secondThreadStart = await waitForMessage(
secondServer,
(message) => message.method === 'thread/start',
);
firstServer.send({ id: firstThreadStart.id, result: { thread: { id: 'thr_deep_crash_1' } } });
secondServer.send({ id: secondThreadStart.id, result: { thread: { id: 'thr_deep_crash_2' } } });
const firstTurnStart = await waitForMessage(
firstServer,
(message) => message.method === 'turn/start',
);
const secondTurnStart = await waitForMessage(
secondServer,
(message) => message.method === 'turn/start',
);
firstServer.send({
id: firstTurnStart.id,
result: { turn: { id: 'turn_deep_crash_1', status: 'inProgress' } },
});
secondServer.send({
id: secondTurnStart.id,
result: { turn: { id: 'turn_deep_crash_2', status: 'inProgress' } },
});
firstServer.proc.exitCode = 1;
firstServer.proc.emit('exit', 1, null);
await expect(firstResultPromise).resolves.toEqual({
error:
'Error calling OpenAI Codex app-server: codex app-server exited with code 1 signal null',
});
secondServer.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_deep_crash_2',
turnId: 'turn_deep_crash_2',
itemId: 'msg_deep_crash_2',
delta: 'Second survived',
},
});
secondServer.send({
method: 'turn/completed',
params: {
threadId: 'thr_deep_crash_2',
turn: { id: 'turn_deep_crash_2', status: 'completed', items: [], error: null },
},
});
await expect(secondResultPromise).resolves.toMatchObject({ output: 'Second survived' });
});
it('waits for completion after retryable app-server error notifications', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Recover after retryable app-server error');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_retry_error' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_retry_error', status: 'inProgress' } },
});
server.send({
method: 'error',
params: {
threadId: 'thr_retry_error',
turnId: 'turn_retry_error',
error: { message: 'temporary app-server error' },
willRetry: true,
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_retry_error',
turnId: 'turn_retry_error',
itemId: 'msg_retry_error',
delta: 'Recovered',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_retry_error',
turn: { id: 'turn_retry_error', status: 'completed', items: [], error: null },
},
});
await expect(resultPromise).resolves.toMatchObject({ output: 'Recovered' });
});
it('parses JSON-RPC notifications whose string payloads contain literal newlines', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
include_raw_events: true,
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run a command with multiline output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_multiline' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_multiline', status: 'inProgress' } },
});
server.stdout.write(
'{"method":"item/commandExecution/outputDelta","params":{"threadId":"thr_multiline","turnId":"turn_multiline","itemId":"cmd_multiline","delta":"line one\n',
);
server.stdout.write('line two"}}\n');
server.send({
method: 'item/completed',
params: {
threadId: 'thr_multiline',
turnId: 'turn_multiline',
item: {
type: 'commandExecution',
id: 'cmd_multiline',
command: 'printf "line one\\nline two"',
cwd: process.cwd(),
status: 'completed',
aggregatedOutput: null,
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_multiline',
turnId: 'turn_multiline',
itemId: 'msg_multiline',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_multiline',
turn: { id: 'turn_multiline', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.notifications).toEqual(
expect.arrayContaining([
expect.objectContaining({
method: 'item/commandExecution/outputDelta',
params: expect.objectContaining({
delta: 'line one\nline two',
}),
}),
]),
);
expect(raw.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'command_execution',
aggregated_output: 'line one\nline two',
}),
]),
);
expect(result.metadata?.codexAppServer.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'commandExecution',
aggregatedOutput: 'line one\nline two',
}),
]),
);
expect(result.output).toBe('Done');
});
it('keeps aggregating command output deltas after command completion', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run a command with delayed output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_delayed_output' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_delayed_output', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_delayed_output',
turnId: 'turn_delayed_output',
item: {
type: 'commandExecution',
id: 'cmd_delayed_output',
command: 'printf "line one\\nline two"',
cwd: process.cwd(),
status: 'completed',
aggregatedOutput: null,
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_delayed_output',
turnId: 'turn_delayed_output',
itemId: 'cmd_delayed_output',
delta: 'line one\n',
},
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_delayed_output',
turnId: 'turn_delayed_output',
itemId: 'cmd_delayed_output',
delta: 'line two',
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_delayed_output',
turnId: 'turn_delayed_output',
itemId: 'msg_delayed_output',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_delayed_output',
turn: { id: 'turn_delayed_output', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'command_execution',
aggregated_output: 'line one\nline two',
}),
]),
);
expect(result.metadata?.codexAppServer.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'commandExecution',
aggregatedOutput: 'line one\nline two',
}),
]),
);
});
it('continues completed command output when later deltas extend an existing aggregate', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run a command with late trailing output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_trailing_output' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_trailing_output', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_trailing_output',
turnId: 'turn_trailing_output',
item: {
type: 'commandExecution',
id: 'cmd_trailing_output',
command: 'printf "line one\\nline two"',
cwd: process.cwd(),
status: 'completed',
aggregatedOutput: 'line one\n',
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_trailing_output',
turnId: 'turn_trailing_output',
itemId: 'cmd_trailing_output',
delta: 'line two',
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_trailing_output',
turnId: 'turn_trailing_output',
itemId: 'msg_trailing_output',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_trailing_output',
turn: { id: 'turn_trailing_output', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'command_execution',
aggregated_output: 'line one\nline two',
}),
]),
);
expect(result.metadata?.codexAppServer.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'commandExecution',
aggregatedOutput: 'line one\nline two',
}),
]),
);
});
it('continues from a completed command aggregate when earlier deltas already exist', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run a command with interleaved output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_interleaved_output' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_interleaved_output', status: 'inProgress' } },
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_interleaved_output',
turnId: 'turn_interleaved_output',
itemId: 'cmd_interleaved_output',
delta: 'line one\n',
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_interleaved_output',
turnId: 'turn_interleaved_output',
item: {
type: 'commandExecution',
id: 'cmd_interleaved_output',
command: 'printf "line one\\nline two\\nline three"',
cwd: process.cwd(),
status: 'completed',
aggregatedOutput: 'line one\nline two\n',
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_interleaved_output',
turnId: 'turn_interleaved_output',
itemId: 'cmd_interleaved_output',
delta: 'line three',
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_interleaved_output',
turnId: 'turn_interleaved_output',
itemId: 'msg_interleaved_output',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_interleaved_output',
turn: { id: 'turn_interleaved_output', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'command_execution',
aggregated_output: 'line one\nline two\nline three',
}),
]),
);
expect(result.metadata?.codexAppServer.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'commandExecution',
aggregatedOutput: 'line one\nline two\nline three',
}),
]),
);
});
it('emits SDK-compatible raw items for coding-agent verifiers', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Inspect raw evidence');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_raw_evidence' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_raw_evidence', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
item: {
type: 'commandExecution',
id: 'cmd_raw_evidence',
command: 'cat env_dump.txt',
cwd: process.cwd(),
status: 'completed',
aggregatedOutput: 'PROMPTFOO_SYNTHETIC_SECRET=synthetic-value',
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
item: {
type: 'reasoning',
id: 'reason_raw_evidence',
summary: [{ text: 'Inspecting env dump.' }],
content: [],
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
item: {
type: 'todoList',
id: 'todo_raw_evidence',
items: [{ text: 'Verify the secret', completed: false }],
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
item: {
type: 'error',
id: 'err_raw_evidence',
message: 'transient stream blip',
},
},
});
server.send({
method: 'thread/tokenUsage/updated',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
tokenUsage: {
last: {
inputTokens: 100,
cachedInputTokens: 25,
outputTokens: 50,
reasoningOutputTokens: 12,
},
total: {
inputTokens: 200,
cachedInputTokens: 25,
outputTokens: 75,
reasoningOutputTokens: 12,
},
},
},
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_raw_evidence',
turnId: 'turn_raw_evidence',
item: {
type: 'agentMessage',
id: 'msg_raw_evidence',
text: 'Done',
status: 'completed',
},
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_raw_evidence',
turn: { id: 'turn_raw_evidence', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.finalResponse).toBe('Done');
expect(raw.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'command_execution',
aggregated_output: 'PROMPTFOO_SYNTHETIC_SECRET=synthetic-value',
exit_code: 0,
}),
expect.objectContaining({
type: 'agent_message',
text: 'Done',
}),
expect.objectContaining({
type: 'reasoning',
text: 'Inspecting env dump.',
}),
expect.objectContaining({
type: 'todo_list',
items: [expect.objectContaining({ text: 'Verify the secret' })],
}),
expect.objectContaining({
type: 'error',
message: 'transient stream blip',
}),
]),
);
expect(raw.usage).toEqual({
input_tokens: 100,
cached_input_tokens: 25,
output_tokens: 50,
reasoning_output_tokens: 12,
});
});
it('counts notifications without retaining raw notification payloads by default', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Run a command with noisy output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_notification_count' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_notification_count', status: 'inProgress' } },
});
server.send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: 'thr_notification_count',
turnId: 'turn_notification_count',
itemId: 'cmd_notification_count',
delta: 'large-delta-payload'.repeat(100),
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_notification_count',
turnId: 'turn_notification_count',
itemId: 'msg_notification_count',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_notification_count',
turn: { id: 'turn_notification_count', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const raw = JSON.parse(result.raw as string);
expect(raw.notifications).toBeUndefined();
expect(result.raw).not.toContain('large-delta-payload');
expect(result.metadata?.codexAppServer.notificationCount).toBe(3);
expect(result.output).toBe('Done');
});
it('sanitizes sensitive command metadata', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Inspect command output');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_sanitize' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_sanitize', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_sanitize',
turnId: 'turn_sanitize',
item: {
type: 'commandExecution',
id: 'cmd_secret',
command: 'echo api_key=sk-proj-abcdefghijklmnopqrstuvwxyz123456',
cwd: process.cwd(),
source: 'shell',
status: 'completed',
commandActions: [],
aggregatedOutput: 'api_key=sk-proj-abcdefghijklmnopqrstuvwxyz123456',
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_sanitize',
turnId: 'turn_sanitize',
itemId: 'msg_secret',
delta: 'Done',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_sanitize',
turn: { id: 'turn_sanitize', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
const metadataJson = JSON.stringify(result.metadata);
expect(metadataJson).not.toContain('sk-proj-abcdefghijklmnopqrstuvwxyz123456');
expect(metadataJson).toContain('[REDACTED]');
const rawJson = result.raw as string;
expect(rawJson).not.toContain('sk-proj-abcdefghijklmnopqrstuvwxyz123456');
expect(rawJson).toContain('[REDACTED]');
});
it('detects skill calls using the resolved app-server process environment', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
cli_env: {
HOME: '/tmp/codex-app-server-home',
},
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Use a skill');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_skill_env' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_skill_env', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_skill_env',
turnId: 'turn_skill_env',
item: {
type: 'commandExecution',
id: 'cmd_skill_env',
command: '/tmp/codex-app-server-home/.codex/skills/home-skill/SKILL.md --help',
cwd: process.cwd(),
status: 'completed',
exitCode: 0,
durationMs: 1,
},
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_skill_env',
turnId: 'turn_skill_env',
itemId: 'msg_skill_env',
delta: 'Skill used',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_skill_env',
turn: { id: 'turn_skill_env', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.metadata?.skillCalls).toEqual([
{
name: 'home-skill',
path: '/tmp/codex-app-server-home/.codex/skills/home-skill/SKILL.md',
source: 'heuristic',
},
]);
});
it('detects repo-local skill calls without accepting wildcard or unrelated skill paths', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Use a repo skill');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_local_skill' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_local_skill', status: 'inProgress' } },
});
for (const [id, command] of [
['cmd_local_skill', '.agents/skills/repo-skill/SKILL.md --help'],
['cmd_wildcard_skill', '.agents/skills/*/SKILL.md --help'],
['cmd_unrelated_skill', '/tmp/unrelated-project/skills/external/SKILL.md --help'],
]) {
server.send({
method: 'item/completed',
params: {
threadId: 'thr_local_skill',
turnId: 'turn_local_skill',
item: {
type: 'commandExecution',
id,
command,
cwd: process.cwd(),
status: 'completed',
exitCode: 0,
durationMs: 1,
},
},
});
}
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_local_skill',
turnId: 'turn_local_skill',
itemId: 'msg_local_skill',
delta: 'Skill used',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_local_skill',
turn: { id: 'turn_local_skill', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.metadata?.skillCalls).toEqual([
{
name: 'repo-skill',
path: '.agents/skills/repo-skill/SKILL.md',
source: 'heuristic',
},
]);
expect(result.metadata?.attemptedSkillCalls).toBeUndefined();
});
it('records attempted repo-local skill calls when command execution fails', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Try a repo skill');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_repo_skill' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_repo_skill', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_repo_skill',
turnId: 'turn_repo_skill',
item: {
type: 'commandExecution',
id: 'cmd_repo_skill',
command: '.agents/skills/repo-skill/SKILL.md --help',
cwd: process.cwd(),
status: 'failed',
exitCode: 1,
durationMs: 1,
},
},
});
server.send({
method: 'item/agentMessage/delta',
params: {
threadId: 'thr_repo_skill',
turnId: 'turn_repo_skill',
itemId: 'msg_repo_skill',
delta: 'Skill attempt recorded',
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_repo_skill',
turn: { id: 'turn_repo_skill', status: 'completed', items: [], error: null },
},
});
const result = await resultPromise;
expect(result.metadata?.skillCalls).toBeUndefined();
expect(result.metadata?.attemptedSkillCalls).toEqual([
{
name: 'repo-skill',
path: '.agents/skills/repo-skill/SKILL.md',
source: 'heuristic',
},
]);
});
it('kills the app-server process during cleanup', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_cleanup' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_cleanup', status: 'inProgress' } },
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_cleanup',
turn: { id: 'turn_cleanup', status: 'completed', items: [], error: null },
},
});
await resultPromise;
await provider.cleanup();
expect(server.proc.kill).toHaveBeenCalledWith('SIGTERM');
});
it('closes a pending app-server process when cleanup runs during initialization', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
startup_timeout_ms: 100,
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
await waitForMessage(server, (message) => message.method === 'initialize');
await provider.cleanup();
await expect(resultPromise).resolves.toMatchObject({
error: expect.stringContaining('codex app-server connection closed'),
});
expect(server.proc.kill).toHaveBeenCalledWith('SIGTERM');
});
it('resolves an active turn with an error during cleanup', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_cleanup_active' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_cleanup_active', status: 'inProgress' } },
});
await provider.cleanup();
await expect(resultPromise).resolves.toMatchObject({
error: expect.stringContaining('provider cleanup interrupted active turn'),
});
expect(server.proc.kill).toHaveBeenCalledWith('SIGTERM');
});
it('surfaces non-retryable error notifications as provider errors', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_err' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({ id: turnStart.id, result: { turn: { id: 'turn_err', status: 'inProgress' } } });
server.send({
method: 'error',
params: {
threadId: 'thr_err',
turnId: 'turn_err',
willRetry: false,
error: { message: 'rate limit exceeded' },
},
});
const result = await resultPromise;
expect(result.error).toContain('rate limit exceeded');
});
it('propagates JSON-RPC error responses as provider errors', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({
id: threadStart.id,
error: { code: -32600, message: 'invalid thread params' },
});
const result = await resultPromise;
expect(result.error).toContain('invalid thread params');
});
it('returns early when abort signal is already aborted before callApi starts', async () => {
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const controller = new AbortController();
controller.abort();
const result = await provider.callApi('Hello', undefined, {
abortSignal: controller.signal,
});
expect(result.error).toBe('OpenAI Codex app-server call aborted before it started');
expect(mocks.spawn).not.toHaveBeenCalled();
});
it('sends thread/archive when thread_cleanup is set to archive', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'archive' },
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_archive' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_archive', status: 'inProgress' } },
});
server.send({
method: 'item/completed',
params: {
threadId: 'thr_archive',
turnId: 'turn_archive',
item: { id: 'item_1', type: 'agentMessage', text: 'Done' },
},
});
server.send({
method: 'turn/completed',
params: { threadId: 'thr_archive', turnId: 'turn_archive', turn: { id: 'turn_archive' } },
});
const archiveRequest = await waitForMessage(
server,
(message) => message.method === 'thread/archive',
);
expect(archiveRequest.params).toMatchObject({ threadId: 'thr_archive' });
server.send({ id: archiveRequest.id, result: {} });
const result = await resultPromise;
expect(result.output).toBe('Done');
});
it('sends correct sandboxPolicy for workspace-write mode', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
sandbox_mode: 'workspace-write',
network_access_enabled: true,
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_ws' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params.sandboxPolicy).toMatchObject({
type: 'workspaceWrite',
networkAccess: true,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
});
expect(turnStart.params.sandboxPolicy.writableRoots).toBeDefined();
expect(turnStart.params.sandboxPolicy.readOnlyAccess).toEqual({ type: 'fullAccess' });
server.send({ id: turnStart.id, result: { turn: { id: 'turn_ws', status: 'inProgress' } } });
server.send({
method: 'turn/completed',
params: { threadId: 'thr_ws', turnId: 'turn_ws', turn: { id: 'turn_ws' } },
});
await resultPromise;
});
it('sends correct sandboxPolicy for danger-full-access mode', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
sandbox_mode: 'danger-full-access',
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_full' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
expect(turnStart.params.sandboxPolicy).toEqual({ type: 'dangerFullAccess' });
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_full', status: 'inProgress' } },
});
server.send({
method: 'turn/completed',
params: { threadId: 'thr_full', turnId: 'turn_full', turn: { id: 'turn_full' } },
});
await resultPromise;
});
it('responds with empty answers for user_input policy set to empty', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
server_request_policy: {
user_input: 'empty',
},
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_ui' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_ui', status: 'inProgress' } },
});
server.send({
id: 100,
method: 'item/tool/requestUserInput',
params: {
threadId: 'thr_ui',
turnId: 'turn_ui',
questions: [{ id: 'q1', label: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }],
},
});
const userInputResponse = await waitForMessage(server, (message) => message.id === 100);
expect(userInputResponse.result.answers.q1).toEqual({ answers: [] });
server.send({
method: 'item/completed',
params: {
threadId: 'thr_ui',
turnId: 'turn_ui',
item: { id: 'item_1', type: 'agentMessage', text: 'Ok' },
},
});
server.send({
method: 'turn/completed',
params: { threadId: 'thr_ui', turnId: 'turn_ui', turn: { id: 'turn_ui' } },
});
const result = await resultPromise;
expect(result.output).toBe('Ok');
});
it('returns JSON-RPC error for unsupported server request methods', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: { thread_cleanup: 'none' },
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_unsupported' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_unsupported', status: 'inProgress' } },
});
server.send({
id: 200,
method: 'some/unknownMethod',
params: { threadId: 'thr_unsupported', turnId: 'turn_unsupported' },
});
const errorResponse = await waitForMessage(
server,
(message) => message.id === 200 && message.error !== undefined,
);
expect(errorResponse.error.message).toContain('Unsupported codex app-server request');
server.send({
method: 'item/completed',
params: {
threadId: 'thr_unsupported',
turnId: 'turn_unsupported',
item: { id: 'item_1', type: 'agentMessage', text: 'Done' },
},
});
server.send({
method: 'turn/completed',
params: {
threadId: 'thr_unsupported',
turnId: 'turn_unsupported',
turn: { id: 'turn_unsupported' },
},
});
const result = await resultPromise;
expect(result.output).toBe('Done');
});
it('responds with map-based user input answers', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
thread_cleanup: 'none',
server_request_policy: {
user_input: {
q1: 'Option A',
q2: ['X', 'Y'],
},
},
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_map' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_map', status: 'inProgress' } },
});
server.send({
id: 300,
method: 'item/tool/requestUserInput',
params: {
threadId: 'thr_map',
turnId: 'turn_map',
questions: [
{ id: 'q1', label: 'Single answer' },
{ id: 'q2', label: 'Multi answer' },
{ id: 'q3', label: 'Unconfigured' },
],
},
});
const userInputResponse = await waitForMessage(server, (message) => message.id === 300);
expect(userInputResponse.result.answers.q1).toEqual({ answers: ['Option A'] });
expect(userInputResponse.result.answers.q2).toEqual({ answers: ['X', 'Y'] });
expect(userInputResponse.result.answers.q3).toEqual({ answers: [] });
server.send({
method: 'turn/completed',
params: { threadId: 'thr_map', turnId: 'turn_map', turn: { id: 'turn_map' } },
});
await resultPromise;
});
it('propagates base_url to spawn environment', async () => {
const server = createMockAppServer();
mocks.spawn.mockReturnValue(server.proc);
const provider = new OpenAICodexAppServerProvider({
config: {
apiKey: 'test-key',
base_url: 'https://custom.example.com/v1',
thread_cleanup: 'none',
},
});
const resultPromise = provider.callApi('Hello');
const initialize = await waitForMessage(server, (message) => message.method === 'initialize');
server.send({ id: initialize.id, result: {} });
const spawnEnv = mocks.spawn.mock.calls[0][2].env;
expect(spawnEnv.OPENAI_BASE_URL).toBe('https://custom.example.com/v1');
expect(spawnEnv.OPENAI_API_BASE_URL).toBe('https://custom.example.com/v1');
const loginStart = await waitForMessage(
server,
(message) => message.method === 'account/login/start',
);
expect(loginStart.params).toEqual({ type: 'apiKey', apiKey: 'test-key' });
server.send({ id: loginStart.id, result: { type: 'apiKey' } });
const threadStart = await waitForMessage(
server,
(message) => message.method === 'thread/start',
);
server.send({ id: threadStart.id, result: { thread: { id: 'thr_base' } } });
const turnStart = await waitForMessage(server, (message) => message.method === 'turn/start');
server.send({
id: turnStart.id,
result: { turn: { id: 'turn_base', status: 'inProgress' } },
});
server.send({
method: 'turn/completed',
params: { threadId: 'thr_base', turnId: 'turn_base', turn: { id: 'turn_base' } },
});
await resultPromise;
});
});