mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
3753 lines
158 KiB
TypeScript
3753 lines
158 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { MessageQueue2 } from '@/utils/MessageQueue2';
|
|
import type { EnhancedMode } from './loop';
|
|
|
|
const harness = vi.hoisted(() => ({
|
|
notifications: [] as Array<{ method: string; params: unknown }>,
|
|
dispatchNotification: null as ((method: string, params: unknown) => void) | null,
|
|
registerRequestCalls: [] as string[],
|
|
requestHandlers: new Map<string, (params: unknown) => Promise<unknown> | unknown>(),
|
|
initializeCalls: [] as unknown[],
|
|
configReadCalls: [] as unknown[],
|
|
configReadResponse: { config: {} } as { config: Record<string, unknown> },
|
|
failConfigRead: false,
|
|
setFeatureEnablementCalls: [] as unknown[],
|
|
failSetFeatureEnablement: false,
|
|
listCollaborationModeCalls: 0,
|
|
collaborationModeResponse: { data: [{ mode: 'default' }, { mode: 'plan' }] } as unknown,
|
|
failListCollaborationModes: false,
|
|
listSkillsCalls: [] as unknown[],
|
|
skillsListResponse: {
|
|
data: [{
|
|
cwd: '/tmp/hapi-update',
|
|
skills: [{
|
|
name: 'hapi',
|
|
description: 'Manage HAPI',
|
|
path: '/home/user/.agents/skills/hapi/SKILL.md',
|
|
scope: 'user',
|
|
enabled: true
|
|
}],
|
|
errors: []
|
|
}]
|
|
} as unknown,
|
|
startThreadIds: [] as string[],
|
|
startThreadParams: [] as Array<Record<string, unknown>>,
|
|
resumeThreadIds: [] as string[],
|
|
resumeThreadParams: [] as Array<Record<string, unknown>>,
|
|
startTurnThreadIds: [] as string[],
|
|
startTurnParams: [] as Array<Record<string, unknown>>,
|
|
startTurnErrors: [] as Error[],
|
|
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
|
|
interruptErrors: [] as Error[],
|
|
rollbackCalls: [] as Array<{ threadId: string; numTurns: number }>,
|
|
rollbackErrors: [] as Error[],
|
|
compactThreadIds: [] as string[],
|
|
goalSetCalls: [] as unknown[],
|
|
goalGetCalls: [] as unknown[],
|
|
goalClearCalls: [] as unknown[],
|
|
goal: null as Record<string, unknown> | null,
|
|
suppressGoalNotifications: false,
|
|
suppressTurnCompletion: false,
|
|
remainingThreadSystemErrors: 0,
|
|
emitFailedCompletionAfterThreadSystemError: false,
|
|
emitCyberPolicyAfterThreadSystemError: false,
|
|
emitSafetyBuffering: false,
|
|
safetyBufferingFasterModel: null as string | null,
|
|
emitModelSafetyNotices: false,
|
|
startTurnMessages: [] as string[],
|
|
failResumeThreadIds: [] as string[],
|
|
nextThreadSystemErrorMessage: null as string | null,
|
|
failNextCompact: false,
|
|
deferCompactCompletion: false,
|
|
deferThreadStatusNotifications: false,
|
|
steerTurnParams: [] as Array<Record<string, unknown>>,
|
|
steerDispatchError: null as Error | null,
|
|
steerCompletionError: null as Error | null,
|
|
readThreadParams: [] as Array<Record<string, unknown>>,
|
|
readThreadError: null as Error | null,
|
|
readThreadResponse: { thread: { turns: [] as unknown[] } } as { thread: { turns?: Array<{ items?: Array<Record<string, unknown>> }> } },
|
|
emitStaleTaskCompleteAfterRetry: false,
|
|
emitStaleTaskFailedAfterRetry: false,
|
|
emitStaleThreadStatusFailureAfterRetry: false,
|
|
emitFirstTurnTaskCompleteAfterSecondRetry: false,
|
|
emitChildThreadEvents: false,
|
|
emitChildUsageEvents: false,
|
|
emitChildGoalEvent: false,
|
|
emitChildReasoningBurst: false,
|
|
emitChildDoneStatusWithoutMessage: false,
|
|
emitChildWaitStructuredOutput: false,
|
|
emitChildTaskCompleteBeforeMessage: false,
|
|
suppressChildTaskCompleteEvent: false,
|
|
emitSecondChildMessage: false,
|
|
emitLateChildCommandAfterParentTool: false,
|
|
emitParentUsageEvents: false,
|
|
emitParentGoalDuplicateEvents: false,
|
|
emitChildNestedAgentTool: false,
|
|
emitParentTitleChange: false,
|
|
emitParentSpawnFailureWithoutAgentId: false,
|
|
emitParentSpawnStartWithoutEnd: false,
|
|
emitParentSpawnRouterStderrError: false,
|
|
emitChildTaskStartedAfterParentSpawnStart: false,
|
|
emitSecondParentSpawnStartWithoutEnd: false,
|
|
emitParentSendInputFailure: false,
|
|
emitParentResumeSuccess: false,
|
|
emitParentV2AgentTools: false,
|
|
emitRunningChildTurnBeforeSuppressedParent: false,
|
|
emitCompletedChildTurnBeforeSuppressedParent: false,
|
|
emitTurnAbortedOnInterrupt: false,
|
|
bridgeOptions: [] as unknown[]
|
|
}));
|
|
|
|
vi.mock('./codexAppServerClient', () => {
|
|
const INDETERMINATE_SYMBOL = Symbol('codex-app-server-indeterminate');
|
|
class MockCodexAppServerClient {
|
|
private notificationHandler: ((method: string, params: unknown) => void) | null = null;
|
|
private stderrHandler: ((text: string) => void) | null = null;
|
|
|
|
async connect(): Promise<void> {}
|
|
|
|
isConnected(): boolean {
|
|
return true;
|
|
}
|
|
|
|
isInitialized(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async initialize(params: unknown): Promise<{ protocolVersion: number }> {
|
|
harness.initializeCalls.push(params);
|
|
return { protocolVersion: 1 };
|
|
}
|
|
|
|
async readConfig(params: unknown): Promise<{ config: Record<string, unknown> }> {
|
|
harness.configReadCalls.push(params);
|
|
if (harness.failConfigRead) {
|
|
throw new Error('config/read unsupported');
|
|
}
|
|
return harness.configReadResponse;
|
|
}
|
|
|
|
setNotificationHandler(handler: ((method: string, params: unknown) => void) | null): void {
|
|
this.notificationHandler = handler;
|
|
harness.dispatchNotification = handler;
|
|
}
|
|
|
|
setTransportAbandonedHandler(_handler: (() => void) | null): void {}
|
|
|
|
setStderrHandler(handler: ((text: string) => void) | null): void {
|
|
this.stderrHandler = handler;
|
|
}
|
|
|
|
async listCollaborationModes(): Promise<unknown> {
|
|
harness.listCollaborationModeCalls += 1;
|
|
if (harness.failListCollaborationModes) {
|
|
throw new Error('collaborationMode/list failed');
|
|
}
|
|
return harness.collaborationModeResponse;
|
|
}
|
|
|
|
async listSkills(params: unknown): Promise<unknown> {
|
|
harness.listSkillsCalls.push(params);
|
|
return harness.skillsListResponse;
|
|
}
|
|
|
|
async setExperimentalFeatureEnablement(params: unknown): Promise<unknown> {
|
|
harness.setFeatureEnablementCalls.push(params);
|
|
if (harness.failSetFeatureEnablement) {
|
|
throw new Error('unsupported feature enablement');
|
|
}
|
|
return params;
|
|
}
|
|
|
|
registerRequestHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void {
|
|
harness.registerRequestCalls.push(method);
|
|
harness.requestHandlers.set(method, handler);
|
|
}
|
|
|
|
async startThread(params?: Record<string, unknown>): Promise<{ thread: { id: string }; model: string }> {
|
|
const id = `thread-${harness.startThreadIds.length + 1}`;
|
|
harness.startThreadIds.push(id);
|
|
harness.startThreadParams.push(params ?? {});
|
|
return { thread: { id }, model: 'gpt-5.4' };
|
|
}
|
|
|
|
async resumeThread(params?: Record<string, unknown>): Promise<{ thread: { id: string }; model: string }> {
|
|
const id = typeof params?.threadId === 'string' ? params.threadId : 'thread-resumed';
|
|
harness.resumeThreadIds.push(id);
|
|
harness.resumeThreadParams.push(params ?? {});
|
|
if (harness.failResumeThreadIds.includes(id)) {
|
|
throw new Error('resume failed');
|
|
}
|
|
return { thread: { id }, model: 'gpt-5.4' };
|
|
}
|
|
|
|
async compactThread(params?: { threadId?: string }): Promise<Record<string, never>> {
|
|
const threadId = params?.threadId ?? 'thread-unknown';
|
|
harness.compactThreadIds.push(threadId);
|
|
if (harness.failNextCompact) {
|
|
harness.failNextCompact = false;
|
|
throw new Error('compact failed');
|
|
}
|
|
if (harness.deferCompactCompletion) {
|
|
return {};
|
|
}
|
|
const compacted = { threadId, turnId: `compact-${harness.compactThreadIds.length}` };
|
|
harness.notifications.push({ method: 'thread/compacted', params: compacted });
|
|
this.notificationHandler?.('thread/compacted', compacted);
|
|
return {};
|
|
}
|
|
|
|
async setThreadGoal(params?: { threadId?: string; objective?: string; status?: string }): Promise<{ goal: Record<string, unknown> }> {
|
|
harness.goalSetCalls.push(params ?? {});
|
|
const threadId = params?.threadId ?? 'thread-unknown';
|
|
harness.goal = {
|
|
threadId,
|
|
objective: params?.objective ?? harness.goal?.objective ?? 'existing goal',
|
|
status: params?.status ?? 'active',
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
};
|
|
const notification = { threadId, goal: harness.goal };
|
|
if (!harness.suppressGoalNotifications) {
|
|
harness.notifications.push({ method: 'thread/goal/updated', params: notification });
|
|
this.notificationHandler?.('thread/goal/updated', notification);
|
|
}
|
|
return { goal: harness.goal };
|
|
}
|
|
|
|
async getThreadGoal(params?: { threadId?: string }): Promise<{ goal: Record<string, unknown> | null }> {
|
|
harness.goalGetCalls.push(params ?? {});
|
|
return { goal: harness.goal };
|
|
}
|
|
|
|
async steerTurn(params?: { threadId?: string; input?: unknown[]; expectedTurnId?: string; clientUserMessageId?: string }): Promise<{
|
|
dispatched: Promise<void>;
|
|
completed: Promise<unknown>;
|
|
}> {
|
|
harness.steerTurnParams.push((params ?? {}) as Record<string, unknown>);
|
|
if (harness.steerDispatchError) {
|
|
return {
|
|
dispatched: Promise.reject(harness.steerDispatchError),
|
|
completed: Promise.reject(harness.steerDispatchError)
|
|
};
|
|
}
|
|
return {
|
|
dispatched: Promise.resolve(),
|
|
completed: harness.steerCompletionError
|
|
? Promise.reject(harness.steerCompletionError)
|
|
: Promise.resolve({ turnId: 'steered-turn' })
|
|
};
|
|
}
|
|
|
|
async readThread(params?: { threadId?: string; includeTurns?: boolean }): Promise<unknown> {
|
|
harness.readThreadParams.push((params ?? {}) as Record<string, unknown>);
|
|
if (harness.readThreadError) {
|
|
throw harness.readThreadError;
|
|
}
|
|
return harness.readThreadResponse;
|
|
}
|
|
|
|
async clearThreadGoal(params?: { threadId?: string }): Promise<{ cleared: boolean }> {
|
|
harness.goalClearCalls.push(params ?? {});
|
|
const cleared = harness.goal !== null;
|
|
harness.goal = null;
|
|
if (cleared) {
|
|
const notification = { threadId: params?.threadId ?? 'thread-unknown' };
|
|
if (!harness.suppressGoalNotifications) {
|
|
harness.notifications.push({ method: 'thread/goal/cleared', params: notification });
|
|
this.notificationHandler?.('thread/goal/cleared', notification);
|
|
}
|
|
}
|
|
return { cleared };
|
|
}
|
|
|
|
async startTurn(params?: { threadId?: string; input?: Array<{ text?: string }>; message?: string; userMessage?: string }): Promise<{ turn: { id?: string } }> {
|
|
harness.startTurnParams.push((params ?? {}) as Record<string, unknown>);
|
|
const nextError = harness.startTurnErrors.shift();
|
|
if (nextError) {
|
|
throw nextError;
|
|
}
|
|
const threadId = params?.threadId ?? 'thread-unknown';
|
|
harness.startTurnThreadIds.push(threadId);
|
|
harness.startTurnMessages.push(params?.input?.[0]?.text ?? params?.message ?? params?.userMessage ?? '');
|
|
const turnId = `turn-${harness.startTurnThreadIds.length}`;
|
|
const started = { turn: { id: turnId } };
|
|
harness.notifications.push({ method: 'turn/started', params: started });
|
|
this.notificationHandler?.('turn/started', started);
|
|
|
|
if (harness.remainingThreadSystemErrors > 0) {
|
|
harness.remainingThreadSystemErrors -= 1;
|
|
const failed = {
|
|
thread: { id: threadId },
|
|
status: { type: 'systemError', ...(harness.nextThreadSystemErrorMessage ? { message: harness.nextThreadSystemErrorMessage } : {}) }
|
|
};
|
|
harness.notifications.push({ method: 'thread/status/changed', params: failed });
|
|
const notify = () => this.notificationHandler?.('thread/status/changed', failed);
|
|
if (harness.deferThreadStatusNotifications) {
|
|
setTimeout(notify, 0);
|
|
} else {
|
|
notify();
|
|
}
|
|
if (harness.emitCyberPolicyAfterThreadSystemError) {
|
|
const policyError = {
|
|
threadId,
|
|
turnId,
|
|
error: {
|
|
message: 'This content was flagged for possible cybersecurity risk.',
|
|
codexErrorInfo: 'cyberPolicy'
|
|
},
|
|
willRetry: false
|
|
};
|
|
harness.notifications.push({ method: 'error', params: policyError });
|
|
this.notificationHandler?.('error', policyError);
|
|
|
|
const completed = {
|
|
threadId,
|
|
turnId,
|
|
turn: { id: turnId, status: 'failed' }
|
|
};
|
|
harness.notifications.push({ method: 'turn/completed', params: completed });
|
|
this.notificationHandler?.('turn/completed', completed);
|
|
} else if (harness.emitFailedCompletionAfterThreadSystemError) {
|
|
const completed = {
|
|
threadId,
|
|
turnId,
|
|
turn: { id: turnId, status: 'failed' }
|
|
};
|
|
harness.notifications.push({ method: 'turn/completed', params: completed });
|
|
this.notificationHandler?.('turn/completed', completed);
|
|
}
|
|
return { turn: { id: turnId } };
|
|
}
|
|
|
|
if (harness.emitSafetyBuffering) {
|
|
harness.emitSafetyBuffering = false;
|
|
const notification = {
|
|
threadId,
|
|
turnId,
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: true,
|
|
fasterModel: harness.safetyBufferingFasterModel
|
|
};
|
|
harness.notifications.push({ method: 'model/safetyBuffering/updated', params: notification });
|
|
this.notificationHandler?.('model/safetyBuffering/updated', notification);
|
|
return { turn: { id: turnId } };
|
|
}
|
|
|
|
if (harness.emitModelSafetyNotices) {
|
|
harness.emitModelSafetyNotices = false;
|
|
const rerouted = {
|
|
threadId,
|
|
turnId,
|
|
fromModel: 'gpt-5.4',
|
|
toModel: 'gpt-5.4-codex',
|
|
reason: 'highRiskCyberActivity'
|
|
};
|
|
harness.notifications.push({ method: 'model/rerouted', params: rerouted });
|
|
this.notificationHandler?.('model/rerouted', rerouted);
|
|
|
|
const verification = {
|
|
threadId,
|
|
turnId,
|
|
verifications: ['trustedAccessForCyber']
|
|
};
|
|
harness.notifications.push({ method: 'model/verification', params: verification });
|
|
this.notificationHandler?.('model/verification', verification);
|
|
}
|
|
|
|
if (
|
|
harness.emitRunningChildTurnBeforeSuppressedParent
|
|
|| harness.emitCompletedChildTurnBeforeSuppressedParent
|
|
) {
|
|
const childStarted = {
|
|
msg: {
|
|
type: 'task_started',
|
|
thread_id: 'child-thread',
|
|
turn_id: 'child-turn'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_started', params: childStarted });
|
|
this.notificationHandler?.('codex/event/task_started', childStarted);
|
|
|
|
if (harness.emitCompletedChildTurnBeforeSuppressedParent) {
|
|
const childCompleted = {
|
|
msg: {
|
|
type: 'task_complete',
|
|
thread_id: 'child-thread',
|
|
turn_id: 'child-turn'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_complete', params: childCompleted });
|
|
this.notificationHandler?.('codex/event/task_complete', childCompleted);
|
|
}
|
|
}
|
|
|
|
if (harness.suppressTurnCompletion) {
|
|
return { turn: { id: turnId } };
|
|
}
|
|
|
|
if (params?.threadId === 'thread-1') {
|
|
if (harness.emitParentGoalDuplicateEvents) {
|
|
const goalBase = {
|
|
threadId,
|
|
objective: 'keep benchmark work moving',
|
|
status: 'active',
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1
|
|
};
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const notification = {
|
|
threadId,
|
|
goal: {
|
|
...goalBase,
|
|
timeUsedSeconds: index,
|
|
updatedAt: 2 + index
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/goal/updated', params: notification });
|
|
this.notificationHandler?.('thread/goal/updated', notification);
|
|
}
|
|
const pausedNotification = {
|
|
threadId,
|
|
goal: {
|
|
...goalBase,
|
|
status: 'paused',
|
|
timeUsedSeconds: 4,
|
|
updatedAt: 6
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/goal/updated', params: pausedNotification });
|
|
this.notificationHandler?.('thread/goal/updated', pausedNotification);
|
|
}
|
|
|
|
if (harness.emitParentTitleChange) {
|
|
const titleStart = {
|
|
item: {
|
|
id: 'title-parent',
|
|
type: 'mcpToolCall',
|
|
server: 'hapi',
|
|
tool: 'change_title',
|
|
arguments: { title: 'Parent Title' }
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: titleStart });
|
|
this.notificationHandler?.('item/started', titleStart);
|
|
|
|
const titleEnd = {
|
|
item: {
|
|
id: 'title-parent',
|
|
type: 'mcpToolCall',
|
|
server: 'hapi',
|
|
tool: 'change_title',
|
|
result: {
|
|
content: [
|
|
{ type: 'text', text: 'Successfully changed chat title to: "Parent Title"' }
|
|
]
|
|
}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: titleEnd });
|
|
this.notificationHandler?.('item/completed', titleEnd);
|
|
}
|
|
|
|
const commandStart = {
|
|
item: {
|
|
id: 'cmd-1',
|
|
type: 'commandExecution',
|
|
command: 'echo ok',
|
|
cwd: '/tmp/hapi-update'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: commandStart });
|
|
this.notificationHandler?.('item/started', commandStart);
|
|
this.notificationHandler?.('item/commandExecution/outputDelta', {
|
|
itemId: 'cmd-1',
|
|
delta: 'ok\n'
|
|
});
|
|
const commandEnd = {
|
|
item: {
|
|
id: 'cmd-1',
|
|
type: 'commandExecution',
|
|
exitCode: 0
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: commandEnd });
|
|
this.notificationHandler?.('item/completed', commandEnd);
|
|
|
|
if (harness.emitParentUsageEvents) {
|
|
const parentUsage = {
|
|
tokenUsage: {
|
|
thread_id: threadId,
|
|
turn_id: turnId,
|
|
last_token_usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 10
|
|
},
|
|
model_context_window: 200_000
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: parentUsage });
|
|
this.notificationHandler?.('thread/tokenUsage/updated', parentUsage);
|
|
|
|
const parentCompact = { thread: { id: threadId } };
|
|
harness.notifications.push({ method: 'thread/compacted', params: parentCompact });
|
|
this.notificationHandler?.('thread/compacted', parentCompact);
|
|
}
|
|
|
|
if (harness.emitParentV2AgentTools) {
|
|
const v2Calls = [{
|
|
callId: 'v2-spawn',
|
|
name: 'spawn_agent',
|
|
input: { task_name: 'review', message: 'Review this' },
|
|
output: { task_name: '/root/review', nickname: 'Reviewer' }
|
|
}, {
|
|
callId: 'v2-wait',
|
|
name: 'wait_agent',
|
|
input: { timeout_ms: 10_000 },
|
|
output: { message: 'Wait timed out.', timed_out: true }
|
|
}];
|
|
|
|
for (const call of v2Calls) {
|
|
const started = {
|
|
threadId,
|
|
turnId,
|
|
item: {
|
|
type: 'function_call',
|
|
namespace: 'collaboration',
|
|
name: call.name,
|
|
arguments: JSON.stringify(call.input),
|
|
call_id: call.callId
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'rawResponseItem/completed', params: started });
|
|
this.notificationHandler?.('rawResponseItem/completed', started);
|
|
|
|
const completed = {
|
|
threadId,
|
|
turnId,
|
|
item: {
|
|
type: 'function_call_output',
|
|
call_id: call.callId,
|
|
output: JSON.stringify(call.output)
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'rawResponseItem/completed', params: completed });
|
|
this.notificationHandler?.('rawResponseItem/completed', completed);
|
|
}
|
|
}
|
|
|
|
if (harness.emitParentSpawnFailureWithoutAgentId || harness.emitParentSpawnStartWithoutEnd) {
|
|
const spawnStart = {
|
|
item: {
|
|
id: 'failed-spawn',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'spawnAgent',
|
|
prompt: 'do side work',
|
|
reasoningEffort: 'medium',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: []
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: spawnStart });
|
|
this.notificationHandler?.('item/started', spawnStart);
|
|
|
|
if (harness.emitSecondParentSpawnStartWithoutEnd) {
|
|
const secondSpawnStart = {
|
|
item: {
|
|
id: 'second-spawn',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'spawnAgent',
|
|
prompt: 'do other side work',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: []
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: secondSpawnStart });
|
|
this.notificationHandler?.('item/started', secondSpawnStart);
|
|
}
|
|
|
|
if (harness.emitParentSpawnRouterStderrError) {
|
|
this.stderrHandler?.(
|
|
'codex_core::tools::router: error=Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' +
|
|
'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.'
|
|
);
|
|
}
|
|
|
|
if (harness.emitChildTaskStartedAfterParentSpawnStart) {
|
|
const childStarted = {
|
|
msg: {
|
|
type: 'task_started',
|
|
thread_id: 'child-thread',
|
|
turn_id: 'child-turn'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_started', params: childStarted });
|
|
this.notificationHandler?.('codex/event/task_started', childStarted);
|
|
}
|
|
|
|
if (harness.emitParentSpawnFailureWithoutAgentId) {
|
|
const spawnCompleted = {
|
|
item: {
|
|
id: 'failed-spawn',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'spawnAgent',
|
|
status: 'failed',
|
|
error: 'invalid spawn arguments',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [],
|
|
agentsStates: {}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: spawnCompleted });
|
|
this.notificationHandler?.('item/completed', spawnCompleted);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (harness.emitChildThreadEvents) {
|
|
const childThreadId = 'child-thread';
|
|
const childTurnId = 'child-turn';
|
|
const childMessage = 'child output should stay hidden';
|
|
const secondChildMessage = 'final child output should win';
|
|
|
|
const emitChildDone = () => {
|
|
const childDone = {
|
|
msg: {
|
|
type: 'task_complete',
|
|
thread_id: childThreadId,
|
|
turn_id: childTurnId
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_complete', params: childDone });
|
|
this.notificationHandler?.('codex/event/task_complete', childDone);
|
|
};
|
|
|
|
if (harness.emitChildReasoningBurst) {
|
|
for (let i = 0; i < 20; i += 1) {
|
|
const reasoningDelta = {
|
|
msg: {
|
|
type: 'reasoning_content_delta',
|
|
item_id: 'child-reasoning',
|
|
delta: `step-${i} `,
|
|
thread_id: childThreadId,
|
|
turn_id: childTurnId
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/reasoning_content_delta', params: reasoningDelta });
|
|
this.notificationHandler?.('codex/event/reasoning_content_delta', reasoningDelta);
|
|
}
|
|
}
|
|
|
|
if (harness.emitChildDoneStatusWithoutMessage && harness.emitChildTaskCompleteBeforeMessage) {
|
|
emitChildDone();
|
|
}
|
|
|
|
const childMessageCompleted = {
|
|
item: {
|
|
id: 'child-msg-1',
|
|
type: 'agentMessage',
|
|
content: [{ type: 'text', text: childMessage }]
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: childMessageCompleted });
|
|
this.notificationHandler?.('item/completed', childMessageCompleted);
|
|
|
|
if (harness.emitSecondChildMessage) {
|
|
const secondChildMessageCompleted = {
|
|
item: {
|
|
id: 'child-msg-2',
|
|
type: 'agentMessage',
|
|
content: [{ type: 'text', text: secondChildMessage }]
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: secondChildMessageCompleted });
|
|
this.notificationHandler?.('item/completed', secondChildMessageCompleted);
|
|
}
|
|
|
|
if (
|
|
harness.emitChildDoneStatusWithoutMessage
|
|
&& !harness.emitChildTaskCompleteBeforeMessage
|
|
&& !harness.suppressChildTaskCompleteEvent
|
|
) {
|
|
emitChildDone();
|
|
}
|
|
|
|
if (harness.emitChildUsageEvents) {
|
|
const childUsage = {
|
|
tokenUsage: {
|
|
thread_id: childThreadId,
|
|
turn_id: childTurnId,
|
|
last_token_usage: {
|
|
input_tokens: 30,
|
|
output_tokens: 3
|
|
},
|
|
model_context_window: 200_000
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: childUsage });
|
|
this.notificationHandler?.('thread/tokenUsage/updated', childUsage);
|
|
|
|
const childCompact = {
|
|
msg: {
|
|
type: 'context_compacted',
|
|
thread_id: childThreadId,
|
|
turn_id: childTurnId
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/context_compacted', params: childCompact });
|
|
this.notificationHandler?.('codex/event/context_compacted', childCompact);
|
|
|
|
const ambiguousUsage = {
|
|
tokenUsage: {
|
|
last_token_usage: {
|
|
input_tokens: 999,
|
|
output_tokens: 1
|
|
}
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: ambiguousUsage });
|
|
this.notificationHandler?.('thread/tokenUsage/updated', ambiguousUsage);
|
|
}
|
|
|
|
if (harness.emitChildGoalEvent) {
|
|
const childGoal = {
|
|
threadId: childThreadId,
|
|
goal: {
|
|
threadId: childThreadId,
|
|
objective: 'child-only goal',
|
|
status: 'active',
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/goal/updated', params: childGoal });
|
|
this.notificationHandler?.('thread/goal/updated', childGoal);
|
|
}
|
|
|
|
const childCommandStart = {
|
|
item: {
|
|
id: 'child-cmd-1',
|
|
type: 'commandExecution',
|
|
command: 'echo child'
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: childCommandStart });
|
|
this.notificationHandler?.('item/started', childCommandStart);
|
|
this.notificationHandler?.('item/commandExecution/outputDelta', {
|
|
itemId: 'child-cmd-1',
|
|
delta: 'child stdout\n',
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
});
|
|
const childCommandEnd = {
|
|
item: {
|
|
id: 'child-cmd-1',
|
|
type: 'commandExecution',
|
|
exitCode: 0
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: childCommandEnd });
|
|
this.notificationHandler?.('item/completed', childCommandEnd);
|
|
|
|
const childTitleStart = {
|
|
item: {
|
|
id: 'title-child',
|
|
type: 'mcpToolCall',
|
|
server: 'hapi',
|
|
tool: 'change_title',
|
|
arguments: { title: 'Child Title' }
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: childTitleStart });
|
|
this.notificationHandler?.('item/started', childTitleStart);
|
|
|
|
const childTitleEnd = {
|
|
item: {
|
|
id: 'title-child',
|
|
type: 'mcpToolCall',
|
|
server: 'hapi',
|
|
tool: 'change_title',
|
|
result: {
|
|
content: [
|
|
{ type: 'text', text: 'Successfully changed chat title to: "Child Title"' }
|
|
]
|
|
}
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: childTitleEnd });
|
|
this.notificationHandler?.('item/completed', childTitleEnd);
|
|
|
|
if (harness.emitChildNestedAgentTool) {
|
|
const nestedSpawnStart = {
|
|
item: {
|
|
id: 'nested-spawn',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'spawn',
|
|
senderThreadId: childThreadId,
|
|
receiverThreadIds: ['grandchild-thread'],
|
|
prompt: 'do nested work'
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: nestedSpawnStart });
|
|
this.notificationHandler?.('item/started', nestedSpawnStart);
|
|
|
|
const nestedSpawnCompleted = {
|
|
item: {
|
|
id: 'nested-spawn',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'spawn',
|
|
status: 'completed',
|
|
senderThreadId: childThreadId,
|
|
receiverThreadIds: ['grandchild-thread'],
|
|
agentsStates: {}
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: nestedSpawnCompleted });
|
|
this.notificationHandler?.('item/completed', nestedSpawnCompleted);
|
|
}
|
|
|
|
const waitStarted = {
|
|
item: {
|
|
id: 'wait-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'wait',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId],
|
|
agentsStates: {}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: waitStarted });
|
|
this.notificationHandler?.('item/started', waitStarted);
|
|
|
|
const waitCompleted = {
|
|
item: {
|
|
id: 'wait-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'wait',
|
|
status: 'completed',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId],
|
|
agentsStates: {
|
|
[childThreadId]: {
|
|
status: harness.emitChildDoneStatusWithoutMessage ? 'done' : 'completed',
|
|
message: harness.emitChildWaitStructuredOutput
|
|
? ''
|
|
: harness.emitChildDoneStatusWithoutMessage
|
|
? null
|
|
: harness.emitSecondChildMessage
|
|
? secondChildMessage
|
|
: childMessage,
|
|
...(harness.emitChildWaitStructuredOutput ? { output: { value: 42 } } : {})
|
|
}
|
|
}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: waitCompleted });
|
|
this.notificationHandler?.('item/completed', waitCompleted);
|
|
|
|
if (harness.emitParentSendInputFailure) {
|
|
const sendInputStarted = {
|
|
item: {
|
|
id: 'send-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'sendInput',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId],
|
|
message: 'follow up'
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: sendInputStarted });
|
|
this.notificationHandler?.('item/started', sendInputStarted);
|
|
|
|
const sendInputCompleted = {
|
|
item: {
|
|
id: 'send-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'sendInput',
|
|
status: 'failed',
|
|
error: 'send failed',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId],
|
|
agentsStates: {}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: sendInputCompleted });
|
|
this.notificationHandler?.('item/completed', sendInputCompleted);
|
|
}
|
|
|
|
if (harness.emitParentResumeSuccess) {
|
|
const resumeStarted = {
|
|
item: {
|
|
id: 'resume-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'resumeAgent',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId]
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: resumeStarted });
|
|
this.notificationHandler?.('item/started', resumeStarted);
|
|
|
|
const resumeCompleted = {
|
|
item: {
|
|
id: 'resume-child',
|
|
type: 'collabAgentToolCall',
|
|
tool: 'resumeAgent',
|
|
status: 'completed',
|
|
senderThreadId: threadId,
|
|
receiverThreadIds: [childThreadId],
|
|
agentsStates: {}
|
|
},
|
|
threadId,
|
|
turnId
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: resumeCompleted });
|
|
this.notificationHandler?.('item/completed', resumeCompleted);
|
|
}
|
|
|
|
if (harness.emitLateChildCommandAfterParentTool) {
|
|
const lateChildCommandStart = {
|
|
item: {
|
|
id: 'late-child-cmd',
|
|
type: 'commandExecution',
|
|
command: 'echo late'
|
|
},
|
|
threadId: childThreadId,
|
|
turnId: childTurnId
|
|
};
|
|
harness.notifications.push({ method: 'item/started', params: lateChildCommandStart });
|
|
this.notificationHandler?.('item/started', lateChildCommandStart);
|
|
}
|
|
}
|
|
|
|
if (harness.emitStaleTaskCompleteAfterRetry && harness.startTurnThreadIds.length === 2) {
|
|
const assistantMessage = {
|
|
item: {
|
|
id: 'stale-retry-message',
|
|
type: 'agentMessage',
|
|
content: [{ type: 'text', text: 'done after retry' }]
|
|
},
|
|
threadId,
|
|
turnId: 'turn-1'
|
|
};
|
|
harness.notifications.push({ method: 'item/completed', params: assistantMessage });
|
|
this.notificationHandler?.('item/completed', assistantMessage);
|
|
|
|
const usage = {
|
|
tokenUsage: {
|
|
thread_id: threadId,
|
|
turn_id: 'turn-1',
|
|
last_token_usage: {
|
|
input_tokens: 10,
|
|
output_tokens: 2
|
|
},
|
|
model_context_window: 200_000
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: usage });
|
|
this.notificationHandler?.('thread/tokenUsage/updated', usage);
|
|
|
|
const staleCompleted = {
|
|
msg: {
|
|
type: 'task_complete',
|
|
thread_id: threadId,
|
|
turn_id: 'turn-1'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_complete', params: staleCompleted });
|
|
this.notificationHandler?.('codex/event/task_complete', staleCompleted);
|
|
return { turn: { id: turnId } };
|
|
}
|
|
|
|
if (harness.emitStaleTaskFailedAfterRetry && harness.startTurnThreadIds.length === 2) {
|
|
const staleFailed = {
|
|
msg: {
|
|
type: 'task_failed',
|
|
thread_id: threadId,
|
|
turn_id: 'turn-1',
|
|
error: 'Codex thread entered systemError'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_failed', params: staleFailed });
|
|
this.notificationHandler?.('codex/event/task_failed', staleFailed);
|
|
}
|
|
|
|
if (harness.emitStaleThreadStatusFailureAfterRetry && harness.startTurnThreadIds.length === 2) {
|
|
const staleThreadStatus = {
|
|
thread: { id: threadId },
|
|
turnId: 'turn-1',
|
|
status: { type: 'systemError' }
|
|
};
|
|
harness.notifications.push({ method: 'thread/status/changed', params: staleThreadStatus });
|
|
this.notificationHandler?.('thread/status/changed', staleThreadStatus);
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
}
|
|
|
|
if (harness.emitFirstTurnTaskCompleteAfterSecondRetry && harness.startTurnThreadIds.length === 3) {
|
|
const staleCompleted = {
|
|
msg: {
|
|
type: 'task_complete',
|
|
thread_id: threadId,
|
|
turn_id: 'turn-1'
|
|
}
|
|
};
|
|
harness.notifications.push({ method: 'codex/event/task_complete', params: staleCompleted });
|
|
this.notificationHandler?.('codex/event/task_complete', staleCompleted);
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
}
|
|
|
|
const completed = { status: 'Completed', turn: { id: turnId } };
|
|
harness.notifications.push({ method: 'turn/completed', params: completed });
|
|
this.notificationHandler?.('turn/completed', completed);
|
|
|
|
return { turn: { id: turnId } };
|
|
}
|
|
|
|
async interruptTurn(params?: { threadId?: string; turnId?: string }): Promise<Record<string, never>> {
|
|
const threadId = params?.threadId ?? 'thread-unknown';
|
|
const turnId = params?.turnId ?? 'turn-unknown';
|
|
harness.interruptedTurns.push({ threadId, turnId });
|
|
const error = harness.interruptErrors.shift();
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
if (harness.emitTurnAbortedOnInterrupt) {
|
|
const interrupted = {
|
|
threadId,
|
|
turnId,
|
|
status: 'interrupted',
|
|
turn: { id: turnId }
|
|
};
|
|
harness.notifications.push({ method: 'turn/completed', params: interrupted });
|
|
this.notificationHandler?.('turn/completed', interrupted);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async rollbackThread(params?: { threadId?: string; numTurns?: number }): Promise<{ thread: { id: string } }> {
|
|
const threadId = params?.threadId ?? 'thread-unknown';
|
|
harness.rollbackCalls.push({ threadId, numTurns: params?.numTurns ?? 0 });
|
|
const error = harness.rollbackErrors.shift();
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
return { thread: { id: threadId } };
|
|
}
|
|
|
|
async disconnect(): Promise<void> {}
|
|
}
|
|
|
|
return {
|
|
CodexAppServerClient: MockCodexAppServerClient,
|
|
INDETERMINATE_SYMBOL,
|
|
isIndeterminateError: (error: unknown) => Boolean(
|
|
error && (error as Record<symbol, unknown>)[INDETERMINATE_SYMBOL] === true
|
|
)
|
|
};
|
|
});
|
|
|
|
vi.mock('./utils/buildHapiMcpBridge', () => ({
|
|
buildHapiMcpBridge: async (_client: unknown, options?: unknown) => {
|
|
harness.bridgeOptions.push(options);
|
|
return {
|
|
server: {
|
|
stop: () => {}
|
|
},
|
|
mcpServers: {}
|
|
};
|
|
}
|
|
}));
|
|
|
|
import { codexRemoteLauncher, isCurrentSteerHandler } from './codexRemoteLauncher';
|
|
import { INDETERMINATE_SYMBOL } from './codexAppServerClient';
|
|
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
|
|
|
type FakeAgentState = {
|
|
requests: Record<string, unknown>;
|
|
completedRequests: Record<string, unknown>;
|
|
};
|
|
|
|
function createMode(): EnhancedMode {
|
|
return {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'default',
|
|
model: 'gpt-5.4'
|
|
};
|
|
}
|
|
|
|
function createSessionStub(
|
|
messages = ['hello from launcher test'],
|
|
mode = createMode(),
|
|
isolateMessages = false,
|
|
closeQueue = true
|
|
) {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
|
|
messages.forEach((message, index) => {
|
|
if (isolateMessages) {
|
|
queue.pushIsolated(message, mode);
|
|
} else if (index === 0 && messages.length > 1) {
|
|
queue.pushIsolateAndClear(message, mode);
|
|
} else {
|
|
queue.push(message, mode);
|
|
}
|
|
});
|
|
if (closeQueue) {
|
|
queue.close();
|
|
}
|
|
|
|
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
|
|
const codexMessages: unknown[] = [];
|
|
const summaryMessages: unknown[] = [];
|
|
const metadataUpdates: Array<Record<string, unknown>> = [];
|
|
const thinkingChanges: boolean[] = [];
|
|
const foundSessionIds: string[] = [];
|
|
const resetThreadCalls: string[] = [];
|
|
const collaborationModes: Array<EnhancedMode['collaborationMode'] | undefined> = [];
|
|
let currentPermissionMode: EnhancedMode['permissionMode'] = mode.permissionMode;
|
|
let currentModel: string | null | undefined = mode.model;
|
|
let currentModelReasoningEffort = mode.modelReasoningEffort;
|
|
let currentCollaborationMode: EnhancedMode['collaborationMode'] | undefined = mode.collaborationMode;
|
|
let agentState: FakeAgentState = {
|
|
requests: {},
|
|
completedRequests: {}
|
|
};
|
|
let metadata: Record<string, unknown> = {
|
|
path: '/tmp/hapi-update',
|
|
host: 'localhost',
|
|
name: 'issue-triage-#54'
|
|
};
|
|
|
|
const rpcHandlers = new Map<string, (params: unknown) => unknown>();
|
|
const client = {
|
|
rpcHandlerManager: {
|
|
registerHandler(method: string, handler: (params: unknown) => unknown) {
|
|
rpcHandlers.set(method, handler);
|
|
}
|
|
},
|
|
updateMetadata(handler: (current: Record<string, unknown>) => Record<string, unknown>) {
|
|
metadata = handler(metadata);
|
|
metadataUpdates.push({ ...metadata });
|
|
},
|
|
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
|
|
agentState = handler(agentState);
|
|
},
|
|
sendAgentMessage(message: unknown) {
|
|
codexMessages.push(message);
|
|
},
|
|
sendUserMessage(_text: string) {},
|
|
sendClaudeSessionMessage(message: unknown) {
|
|
summaryMessages.push(message);
|
|
},
|
|
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
|
|
sessionEvents.push(event);
|
|
},
|
|
emitMessagesConsumed: vi.fn(),
|
|
emitSteerIndeterminate: vi.fn(),
|
|
setSteerDeliveryState: vi.fn(async () => true)
|
|
};
|
|
|
|
const session = {
|
|
path: '/tmp/hapi-update',
|
|
logPath: '/tmp/hapi-update/test.log',
|
|
client,
|
|
queue,
|
|
codexArgs: undefined,
|
|
codexCliOverrides: undefined,
|
|
sessionId: null as string | null,
|
|
thinking: false,
|
|
getPermissionMode() {
|
|
return currentPermissionMode;
|
|
},
|
|
setModel(nextModel: string | null) {
|
|
currentModel = nextModel;
|
|
},
|
|
getModel() {
|
|
return currentModel;
|
|
},
|
|
setModelReasoningEffort(nextEffort: EnhancedMode['modelReasoningEffort']) {
|
|
currentModelReasoningEffort = nextEffort;
|
|
},
|
|
getCollaborationMode() {
|
|
return currentCollaborationMode;
|
|
},
|
|
setCollaborationMode(nextMode: EnhancedMode['collaborationMode']) {
|
|
currentCollaborationMode = nextMode;
|
|
collaborationModes.push(nextMode);
|
|
},
|
|
onThinkingChange(nextThinking: boolean) {
|
|
session.thinking = nextThinking;
|
|
thinkingChanges.push(nextThinking);
|
|
},
|
|
onSessionFound(id: string) {
|
|
session.sessionId = id;
|
|
foundSessionIds.push(id);
|
|
},
|
|
resetCodexThread() {
|
|
resetThreadCalls.push(session.sessionId ?? 'none');
|
|
session.sessionId = null;
|
|
},
|
|
sendAgentMessage(message: unknown) {
|
|
client.sendAgentMessage(message);
|
|
},
|
|
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
|
|
client.sendSessionEvent(event);
|
|
},
|
|
sendUserMessage(text: string) {
|
|
client.sendUserMessage(text);
|
|
}
|
|
};
|
|
|
|
return {
|
|
session,
|
|
sessionEvents,
|
|
codexMessages,
|
|
summaryMessages,
|
|
metadataUpdates,
|
|
getMetadata: () => metadata,
|
|
thinkingChanges,
|
|
foundSessionIds,
|
|
resetThreadCalls,
|
|
rpcHandlers,
|
|
emitMessagesConsumed: client.emitMessagesConsumed,
|
|
emitSteerIndeterminate: client.emitSteerIndeterminate,
|
|
setPermissionMode: (nextMode: EnhancedMode['permissionMode']) => {
|
|
currentPermissionMode = nextMode;
|
|
},
|
|
getModel: () => currentModel,
|
|
getModelReasoningEffort: () => currentModelReasoningEffort,
|
|
getCollaborationMode: () => currentCollaborationMode,
|
|
collaborationModes,
|
|
getAgentState: () => agentState
|
|
};
|
|
}
|
|
|
|
describe('codexRemoteLauncher', () => {
|
|
it('invalidates queued steer handlers after abort or cleanup', () => {
|
|
expect(isCurrentSteerHandler(3, 3, false)).toBe(true);
|
|
expect(isCurrentSteerHandler(4, 3, false)).toBe(false);
|
|
expect(isCurrentSteerHandler(3, 3, true)).toBe(false);
|
|
});
|
|
|
|
it('steers a queued message into the active turn and acks on dispatch', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
expect(result).toEqual({ steered: true });
|
|
expect(harness.steerTurnParams).toHaveLength(1);
|
|
expect(harness.steerTurnParams[0]).toMatchObject({
|
|
expectedTurnId: 'turn-1',
|
|
clientUserMessageId: 'local-1'
|
|
});
|
|
await vi.waitFor(() => expect(emitMessagesConsumed).toHaveBeenCalledWith(['local-1'], { steered: true }));
|
|
// The suppressed turn never completes, so the launcher stays busy;
|
|
// close the queue and leave the run promise unawaited.
|
|
session.queue.close();
|
|
});
|
|
|
|
it('restores the row when the app-server explicitly rejects after dispatch', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
// Plain Error without the indeterminate marker = definite JSON-RPC
|
|
// rejection: the instruction was not accepted, so restoring is safe.
|
|
harness.steerCompletionError = new Error('turn/steer not allowed here');
|
|
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
// Explicit rejection surfaces as failed (not a false steered), and the
|
|
// row is restored so the normal turn/start path delivers it later.
|
|
expect(result).toEqual({ steered: false, error: 'turn/steer not allowed here' });
|
|
await vi.waitFor(() => expect(session.queue.cancelByLocalId('local-1')).toBe(true));
|
|
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
|
session.queue.close();
|
|
});
|
|
|
|
it('holds an indeterminate steer for explicit retry or cancel', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const indeterminate = new Error('Codex app-server disconnected');
|
|
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
|
harness.steerCompletionError = indeterminate;
|
|
// Reconciliation cannot read the thread either. The row is held out of
|
|
// automatic replay and the user can explicitly retry or cancel it.
|
|
harness.readThreadError = new Error('app-server unreachable');
|
|
const { session, rpcHandlers, emitMessagesConsumed, emitSteerIndeterminate } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
// Transport failure after dispatch: report reconciliation, persist the
|
|
// ambiguous state, and remove it from the automatic queue.
|
|
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
|
expect(emitSteerIndeterminate).toHaveBeenCalledWith(['local-1']);
|
|
expect(session.queue.cancelByLocalId('local-1')).toBe(true);
|
|
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
|
session.queue.close();
|
|
});
|
|
|
|
it('reconciles an indeterminate steer to accepted once the thread shows the message', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const indeterminate = new Error('Codex app-server disconnected');
|
|
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
|
harness.steerCompletionError = indeterminate;
|
|
harness.readThreadResponse = {
|
|
thread: {
|
|
turns: [{
|
|
items: [{ type: 'userMessage', clientId: 'local-1', text: 'steer me' }]
|
|
}]
|
|
}
|
|
};
|
|
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
|
// The scheduled reconciliation (1s timer) reads the thread, finds the
|
|
// message, commits the row and fires the badge — no restore, no
|
|
// duplicate.
|
|
await vi.waitFor(() => expect(emitMessagesConsumed).toHaveBeenCalledWith(['local-1'], { steered: true }), { timeout: 5_000 });
|
|
// Row committed and consumed — the tombstone blocks a stale retry.
|
|
expect(session.queue.cancelByLocalId('local-1')).toBe('consumed');
|
|
session.queue.close();
|
|
});
|
|
|
|
it('reconciles when stdin dispatch fails indeterminately', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const indeterminate = new Error('stdin callback timed out');
|
|
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
|
harness.steerDispatchError = indeterminate;
|
|
harness.readThreadError = new Error('app-server unreachable');
|
|
const { session, rpcHandlers, emitMessagesConsumed, emitSteerIndeterminate } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
|
expect(emitSteerIndeterminate).toHaveBeenCalledWith(['local-1']);
|
|
expect(session.queue.cancelByLocalId('local-1')).toBe(true);
|
|
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
|
session.queue.close();
|
|
});
|
|
|
|
it('restores the queued row when stdin dispatch fails', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.steerDispatchError = new Error('stdin closed');
|
|
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
|
const runPromise = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
|
|
|
session.queue.push('steer me', createMode(), 'local-1');
|
|
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
|
const result = await handler({ localId: 'local-1' });
|
|
|
|
expect(result).toEqual({ steered: false, error: 'stdin closed' });
|
|
await vi.waitFor(() => expect(session.queue.cancelByLocalId('local-1')).toBe(true));
|
|
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
|
session.queue.close();
|
|
});
|
|
afterEach(() => {
|
|
harness.notifications = [];
|
|
harness.dispatchNotification = null;
|
|
harness.registerRequestCalls = [];
|
|
harness.requestHandlers = new Map();
|
|
harness.initializeCalls = [];
|
|
harness.configReadCalls = [];
|
|
harness.configReadResponse = { config: {} };
|
|
harness.failConfigRead = false;
|
|
harness.setFeatureEnablementCalls = [];
|
|
harness.failSetFeatureEnablement = false;
|
|
harness.listCollaborationModeCalls = 0;
|
|
harness.collaborationModeResponse = { data: [{ mode: 'default' }, { mode: 'plan' }] };
|
|
harness.failListCollaborationModes = false;
|
|
harness.listSkillsCalls = [];
|
|
harness.skillsListResponse = {
|
|
data: [{
|
|
cwd: '/tmp/hapi-update',
|
|
skills: [{
|
|
name: 'hapi',
|
|
description: 'Manage HAPI',
|
|
path: '/home/user/.agents/skills/hapi/SKILL.md',
|
|
scope: 'user',
|
|
enabled: true
|
|
}],
|
|
errors: []
|
|
}]
|
|
};
|
|
harness.startThreadIds = [];
|
|
harness.startThreadParams = [];
|
|
harness.resumeThreadIds = [];
|
|
harness.resumeThreadParams = [];
|
|
harness.startTurnThreadIds = [];
|
|
harness.startTurnParams = [];
|
|
harness.startTurnErrors = [];
|
|
harness.interruptedTurns = [];
|
|
harness.interruptErrors = [];
|
|
harness.rollbackCalls = [];
|
|
harness.rollbackErrors = [];
|
|
harness.compactThreadIds = [];
|
|
harness.goalSetCalls = [];
|
|
harness.goalGetCalls = [];
|
|
harness.goalClearCalls = [];
|
|
harness.goal = null;
|
|
harness.suppressGoalNotifications = false;
|
|
harness.suppressTurnCompletion = false;
|
|
harness.emitFailedCompletionAfterThreadSystemError = false;
|
|
harness.emitCyberPolicyAfterThreadSystemError = false;
|
|
harness.emitSafetyBuffering = false;
|
|
harness.safetyBufferingFasterModel = null;
|
|
harness.emitModelSafetyNotices = false;
|
|
harness.startTurnMessages = [];
|
|
harness.failResumeThreadIds = [];
|
|
harness.remainingThreadSystemErrors = 0;
|
|
harness.nextThreadSystemErrorMessage = null;
|
|
harness.failNextCompact = false;
|
|
harness.deferCompactCompletion = false;
|
|
harness.deferThreadStatusNotifications = false;
|
|
harness.steerTurnParams = [];
|
|
harness.steerDispatchError = null;
|
|
harness.steerCompletionError = null;
|
|
harness.readThreadParams = [];
|
|
harness.readThreadError = null;
|
|
harness.readThreadResponse = { thread: { turns: [] } };
|
|
harness.emitStaleTaskCompleteAfterRetry = false;
|
|
harness.emitStaleTaskFailedAfterRetry = false;
|
|
harness.emitStaleThreadStatusFailureAfterRetry = false;
|
|
harness.emitFirstTurnTaskCompleteAfterSecondRetry = false;
|
|
harness.emitChildThreadEvents = false;
|
|
harness.emitChildUsageEvents = false;
|
|
harness.emitChildGoalEvent = false;
|
|
harness.emitChildReasoningBurst = false;
|
|
harness.emitChildDoneStatusWithoutMessage = false;
|
|
harness.emitChildWaitStructuredOutput = false;
|
|
harness.emitChildTaskCompleteBeforeMessage = false;
|
|
harness.suppressChildTaskCompleteEvent = false;
|
|
harness.emitSecondChildMessage = false;
|
|
harness.emitLateChildCommandAfterParentTool = false;
|
|
harness.emitParentUsageEvents = false;
|
|
harness.emitParentGoalDuplicateEvents = false;
|
|
harness.emitChildNestedAgentTool = false;
|
|
harness.emitParentTitleChange = false;
|
|
harness.emitParentSpawnFailureWithoutAgentId = false;
|
|
harness.emitParentSpawnStartWithoutEnd = false;
|
|
harness.emitParentSpawnRouterStderrError = false;
|
|
harness.emitChildTaskStartedAfterParentSpawnStart = false;
|
|
harness.emitSecondParentSpawnStartWithoutEnd = false;
|
|
harness.emitParentSendInputFailure = false;
|
|
harness.emitParentResumeSuccess = false;
|
|
harness.emitParentV2AgentTools = false;
|
|
harness.emitRunningChildTurnBeforeSuppressedParent = false;
|
|
harness.emitCompletedChildTurnBeforeSuppressedParent = false;
|
|
harness.emitTurnAbortedOnInterrupt = false;
|
|
harness.bridgeOptions = [];
|
|
});
|
|
|
|
it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => {
|
|
const {
|
|
session,
|
|
sessionEvents,
|
|
thinkingChanges,
|
|
foundSessionIds,
|
|
getModel
|
|
} = createSessionStub();
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(foundSessionIds).toContain('thread-1');
|
|
expect(getModel()).toBe('gpt-5.4');
|
|
expect(harness.startThreadParams).toHaveLength(1);
|
|
expect(harness.startThreadParams[0]?.threadSource).toBe('user');
|
|
expect(harness.initializeCalls).toEqual([{
|
|
clientInfo: {
|
|
name: 'hapi-codex-client',
|
|
version: '1.0.0'
|
|
},
|
|
capabilities: {
|
|
experimentalApi: true
|
|
}
|
|
}]);
|
|
expect(harness.configReadCalls).toEqual([{
|
|
cwd: '/tmp/hapi-update',
|
|
includeLayers: false
|
|
}]);
|
|
expect(harness.setFeatureEnablementCalls).toEqual([{ enablement: { goals: true } }]);
|
|
expect(harness.notifications.map((entry) => entry.method)).toEqual([
|
|
'turn/started',
|
|
'item/started',
|
|
'item/completed',
|
|
'turn/completed'
|
|
]);
|
|
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
|
|
expect(thinkingChanges).toContain(true);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('forwards effective Codex context settings for fresh and resumed threads', async () => {
|
|
harness.configReadResponse = {
|
|
config: {
|
|
model_context_window: 400_000,
|
|
model_auto_compact_token_limit: 300_000
|
|
}
|
|
};
|
|
|
|
const fresh = createSessionStub();
|
|
await codexRemoteLauncher(fresh.session as never);
|
|
expect(harness.startThreadParams[0]?.config).toMatchObject({
|
|
model_context_window: 400_000,
|
|
model_auto_compact_token_limit: 300_000
|
|
});
|
|
|
|
harness.startThreadParams = [];
|
|
const resumed = createSessionStub();
|
|
resumed.session.sessionId = 'thread-existing';
|
|
await codexRemoteLauncher(resumed.session as never);
|
|
expect(harness.resumeThreadParams[0]?.config).toMatchObject({
|
|
model_context_window: 400_000,
|
|
model_auto_compact_token_limit: 300_000
|
|
});
|
|
});
|
|
|
|
it('forwards user-configured MCP servers into fresh and resumed threads', async () => {
|
|
harness.configReadResponse = {
|
|
config: {
|
|
mcp_servers: {
|
|
'package-manager': {
|
|
command: 'uvx',
|
|
args: ['example-mcp', 'serve'],
|
|
environment_id: 'local',
|
|
enabled: true,
|
|
tool_timeout_sec: 60
|
|
},
|
|
remote: {
|
|
url: 'https://example.test/mcp',
|
|
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const fresh = createSessionStub();
|
|
await codexRemoteLauncher(fresh.session as never);
|
|
const freshConfig = harness.startThreadParams[0]?.config as Record<string, unknown> | undefined;
|
|
const freshPackageManager = freshConfig?.['mcp_servers.package-manager'] as {
|
|
command?: string;
|
|
args?: string[];
|
|
} | undefined;
|
|
expect(harness.startThreadParams[0]?.config).toMatchObject({
|
|
'mcp_servers.remote': {
|
|
url: 'https://example.test/mcp',
|
|
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
|
|
}
|
|
});
|
|
expect(freshPackageManager).toEqual(expect.objectContaining({
|
|
environment_id: 'local',
|
|
enabled: true,
|
|
tool_timeout_sec: 60
|
|
}));
|
|
if (process.platform === 'win32') {
|
|
expect(freshPackageManager?.args).toContain('mcp-proxy');
|
|
} else {
|
|
expect(freshPackageManager).toMatchObject({
|
|
command: 'uvx',
|
|
args: ['example-mcp', 'serve']
|
|
});
|
|
}
|
|
|
|
harness.startThreadParams = [];
|
|
const resumed = createSessionStub();
|
|
resumed.session.sessionId = 'thread-existing';
|
|
await codexRemoteLauncher(resumed.session as never);
|
|
const resumedConfig = harness.resumeThreadParams[0]?.config as Record<string, unknown> | undefined;
|
|
const resumedPackageManager = resumedConfig?.['mcp_servers.package-manager'] as {
|
|
command?: string;
|
|
args?: string[];
|
|
} | undefined;
|
|
expect(harness.resumeThreadParams[0]?.config).toMatchObject({
|
|
'mcp_servers.remote': {
|
|
url: 'https://example.test/mcp'
|
|
}
|
|
});
|
|
expect(resumedPackageManager).toBeDefined();
|
|
if (process.platform === 'win32') {
|
|
expect(resumedPackageManager?.args).toContain('mcp-proxy');
|
|
} else {
|
|
expect(resumedPackageManager).toMatchObject({
|
|
command: 'uvx',
|
|
args: ['example-mcp', 'serve']
|
|
});
|
|
}
|
|
});
|
|
|
|
it('keeps remote sessions working when config/read is unavailable', async () => {
|
|
harness.failConfigRead = true;
|
|
const { session } = createSessionStub();
|
|
|
|
await expect(codexRemoteLauncher(session as never)).resolves.toBe('exit');
|
|
expect(harness.startThreadParams[0]?.config).not.toHaveProperty('model_context_window');
|
|
expect(harness.startThreadParams[0]?.config).not.toHaveProperty('model_auto_compact_token_limit');
|
|
});
|
|
|
|
it('uses the native skill catalog for completion and structured turn input', async () => {
|
|
const { session, rpcHandlers } = createSessionStub(['$hapi inspect']);
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(harness.listSkillsCalls).toEqual([{
|
|
cwds: ['/tmp/hapi-update'],
|
|
forceReload: false
|
|
}]);
|
|
expect(Array.from(rpcHandlers.keys())).toContain('listSkills');
|
|
expect(await rpcHandlers.get('listSkills')?.({})).toEqual({
|
|
success: true,
|
|
skills: [{ name: 'hapi', description: 'Manage HAPI' }]
|
|
});
|
|
expect(harness.startTurnParams[0]?.input).toEqual([
|
|
{ type: 'skill', name: 'hapi', path: '/home/user/.agents/skills/hapi/SKILL.md' },
|
|
{ type: 'text', text: ' inspect' }
|
|
]);
|
|
});
|
|
|
|
it('keeps the filesystem skill handler when native discovery reports errors', async () => {
|
|
harness.skillsListResponse = {
|
|
data: [{
|
|
cwd: '/tmp/hapi-update',
|
|
skills: [],
|
|
errors: ['failed to read skills']
|
|
}]
|
|
};
|
|
const { session, rpcHandlers } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(rpcHandlers.has('listSkills')).toBe(false);
|
|
});
|
|
|
|
it('reloads the native skill catalog after skills/changed', async () => {
|
|
const { session, rpcHandlers } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
harness.skillsListResponse = {
|
|
data: [{
|
|
cwd: '/tmp/hapi-update',
|
|
skills: [{
|
|
name: 'new-skill',
|
|
description: 'New skill',
|
|
path: '/tmp/new-skill/SKILL.md',
|
|
scope: 'repo',
|
|
enabled: true
|
|
}],
|
|
errors: []
|
|
}]
|
|
};
|
|
|
|
harness.dispatchNotification?.('skills/changed', {});
|
|
await vi.waitFor(() => {
|
|
expect(harness.listSkillsCalls.at(-1)).toEqual({
|
|
cwds: ['/tmp/hapi-update'],
|
|
forceReload: true
|
|
});
|
|
});
|
|
|
|
expect(await rpcHandlers.get('listSkills')?.({})).toEqual({
|
|
success: true,
|
|
skills: [{ name: 'new-skill', description: 'New skill' }]
|
|
});
|
|
});
|
|
|
|
it('routes app-server MCP elicitation through the existing user-input transport', async () => {
|
|
const { session, codexMessages, rpcHandlers, setPermissionMode } = createSessionStub();
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
const handler = harness.requestHandlers.get('mcpServer/elicitation/request');
|
|
expect(handler).toBeTypeOf('function');
|
|
const request = {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
serverName: 'qmd',
|
|
mode: 'form',
|
|
message: 'Allow the qmd MCP server to run tool "status"?',
|
|
_meta: null,
|
|
requestedSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
approval: {
|
|
type: 'string',
|
|
enum: ['allow', 'deny']
|
|
}
|
|
},
|
|
required: ['approval']
|
|
}
|
|
};
|
|
|
|
setPermissionMode('yolo');
|
|
const response = handler?.(request);
|
|
await vi.waitFor(() => {
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'request_user_input'
|
|
}));
|
|
});
|
|
const requestMessage = codexMessages.find((message) => (
|
|
typeof message === 'object' && message !== null && 'name' in message && message.name === 'request_user_input'
|
|
)) as { callId: string };
|
|
await rpcHandlers.get('permission')?.({
|
|
id: requestMessage.callId,
|
|
approved: true,
|
|
answers: { approval: { answers: ['allow'] } }
|
|
});
|
|
|
|
await expect(response).resolves.toEqual({
|
|
action: 'accept',
|
|
content: {
|
|
approval: 'allow'
|
|
},
|
|
_meta: null
|
|
});
|
|
|
|
});
|
|
|
|
it('sends Codex plan collaboration mode when the app-server advertises it', async () => {
|
|
const { session } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.listCollaborationModeCalls).toBe(1);
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan',
|
|
settings: {
|
|
model: 'gpt-5.4'
|
|
}
|
|
});
|
|
expect(harness.startTurnParams[0]?.model).toBeUndefined();
|
|
});
|
|
|
|
it('recognizes name-only plan collaboration mode entries', async () => {
|
|
harness.collaborationModeResponse = { data: [{ name: 'plan' }] };
|
|
const { session } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan'
|
|
});
|
|
});
|
|
|
|
it('recognizes alternate collaboration mode list envelopes', async () => {
|
|
harness.collaborationModeResponse = { collaborationModes: [{ id: 'plan' }] };
|
|
const { session } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan'
|
|
});
|
|
});
|
|
|
|
it('does not retry plan turns as normal turns when the runtime rejects the field', async () => {
|
|
harness.startTurnErrors.push(new Error('unknown field collaborationMode'));
|
|
const { session, sessionEvents } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan'
|
|
});
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Plan mode is not supported by this Codex runtime.'
|
|
});
|
|
});
|
|
|
|
it('does not retry plan turns when unsupported errors use spaced collaboration mode wording', async () => {
|
|
harness.startTurnErrors.push(new Error('unsupported collaboration mode'));
|
|
const { session, sessionEvents } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan'
|
|
});
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Plan mode is not supported by this Codex runtime.'
|
|
});
|
|
});
|
|
|
|
it('does not retry unrelated collaborationMode errors as normal turns', async () => {
|
|
harness.startTurnErrors.push(new Error('collaborationMode value failed policy validation'));
|
|
const { session, sessionEvents } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
|
|
});
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Process exited unexpectedly'
|
|
});
|
|
});
|
|
|
|
it('still attempts plan mode when collaborationMode/list omits plan', async () => {
|
|
harness.collaborationModeResponse = { data: [{ mode: 'default' }] };
|
|
const { session, sessionEvents } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnParams).toHaveLength(1);
|
|
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
|
|
mode: 'plan'
|
|
});
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
|
|
});
|
|
});
|
|
|
|
it('sets a Codex goal without starting a duplicate client turn', async () => {
|
|
const { session, sessionEvents, codexMessages, foundSessionIds } = createSessionStub(['/goal improve benchmark coverage']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(foundSessionIds).toEqual(['thread-1']);
|
|
expect(harness.startThreadParams).toHaveLength(1);
|
|
expect(harness.startThreadParams[0]?.threadSource).toBe('user');
|
|
expect(harness.startTurnParams).toHaveLength(0);
|
|
expect(harness.goalSetCalls).toEqual([{
|
|
threadId: 'thread-1',
|
|
objective: 'improve benchmark coverage',
|
|
status: 'active'
|
|
}]);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Goal active'
|
|
});
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'ready'
|
|
});
|
|
expect(codexMessages).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'thread_goal_updated',
|
|
thread_id: 'thread-1',
|
|
goal: expect.objectContaining({
|
|
objective: 'improve benchmark coverage',
|
|
status: 'active'
|
|
})
|
|
})
|
|
]));
|
|
});
|
|
|
|
it('still attempts goal RPC when dynamic goals feature enablement is unsupported', async () => {
|
|
harness.failSetFeatureEnablement = true;
|
|
const { session, sessionEvents } = createSessionStub(['/goal improve benchmark coverage']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.goalSetCalls).toEqual([{
|
|
threadId: 'thread-1',
|
|
objective: 'improve benchmark coverage',
|
|
status: 'active'
|
|
}]);
|
|
expect(harness.startTurnParams).toHaveLength(0);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Goal active'
|
|
});
|
|
});
|
|
|
|
it('forwards goal RPC responses when the app-server does not emit goal notifications', async () => {
|
|
harness.suppressGoalNotifications = true;
|
|
const { session, codexMessages } = createSessionStub(['/goal improve benchmark coverage']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(codexMessages).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'thread_goal_updated',
|
|
thread_id: 'thread-1',
|
|
goal: expect.objectContaining({
|
|
objective: 'improve benchmark coverage',
|
|
status: 'active'
|
|
})
|
|
})
|
|
]));
|
|
});
|
|
|
|
it('does not start client turns for goal commands', async () => {
|
|
const { session } = createSessionStub([
|
|
'/goal improve benchmark coverage',
|
|
'/goal',
|
|
'/goal pause',
|
|
'/goal resume',
|
|
'/goal clear'
|
|
], createMode(), true);
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(harness.startTurnParams).toHaveLength(0);
|
|
});
|
|
|
|
it('tracks externally started turns used by app-server goals', async () => {
|
|
const { session, thinkingChanges } = createSessionStub(['/goal improve benchmark coverage']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.goalSetCalls).toHaveLength(1);
|
|
});
|
|
|
|
harness.dispatchNotification?.('turn/started', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'goal-turn-1' }
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(session.thinking).toBe(true);
|
|
});
|
|
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'goal-turn-1', status: 'completed' }
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
expect(thinkingChanges).toEqual(expect.arrayContaining([true, false]));
|
|
expect(await running).toBe('exit');
|
|
});
|
|
|
|
it('formats usage-limited goal status', async () => {
|
|
harness.goal = {
|
|
threadId: 'thread-1',
|
|
objective: 'improve benchmark coverage',
|
|
status: 'usageLimited',
|
|
tokenBudget: null,
|
|
tokensUsed: 10,
|
|
timeUsedSeconds: 1,
|
|
createdAt: 1,
|
|
updatedAt: 2
|
|
};
|
|
const { session, sessionEvents } = createSessionStub(['/goal']);
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(harness.startTurnParams).toHaveLength(0);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Goal limited by usage · 10 tokens'
|
|
});
|
|
});
|
|
|
|
it('does not emit ready when a goal command interrupts an active turn', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.emitTurnAbortedOnInterrupt = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message', '/goal improve benchmark coverage']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
|
|
expect(harness.goalSetCalls).toEqual([{
|
|
threadId: 'thread-1',
|
|
objective: 'improve benchmark coverage',
|
|
status: 'active'
|
|
}]);
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'ready'
|
|
});
|
|
});
|
|
|
|
it('switches collaboration mode to default after approving exit_plan_mode', async () => {
|
|
const { session, rpcHandlers, collaborationModes, getCollaborationMode } = createSessionStub(['plan this'], {
|
|
permissionMode: 'default',
|
|
collaborationMode: 'plan',
|
|
model: 'gpt-5.4'
|
|
});
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.requestHandlers.has('item/tool/requestApproval')).toBe(true);
|
|
expect(rpcHandlers.has('permission')).toBe(true);
|
|
});
|
|
|
|
const approvalHandler = harness.requestHandlers.get('item/tool/requestApproval');
|
|
const approvalPromise = approvalHandler?.({
|
|
itemId: 'exit-1',
|
|
toolName: 'exit_plan_mode',
|
|
input: { plan: '1. Edit files' }
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(rpcHandlers.has('permission')).toBe(true);
|
|
});
|
|
await rpcHandlers.get('permission')?.({ id: 'exit-1', approved: true, decision: 'approved' });
|
|
|
|
await expect(approvalPromise).resolves.toEqual({ decision: 'accept' });
|
|
await running;
|
|
|
|
expect(collaborationModes).toContain('default');
|
|
expect(getCollaborationMode()).toBe('default');
|
|
});
|
|
|
|
it('surfaces thread-level systemError only after same-thread retries are exhausted', async () => {
|
|
harness.remainingThreadSystemErrors = 4;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.resumeThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1', 'thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message', 'first message', 'first message']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Codex thread entered systemError'
|
|
});
|
|
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('retries a thread-level systemError on the same thread without starting a fresh thread', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.resumeThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Codex thread entered systemError'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('emits ready when same-thread retry completes with a stale terminal turn id', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.emitStaleTaskCompleteAfterRetry = true;
|
|
const {
|
|
session,
|
|
sessionEvents,
|
|
codexMessages,
|
|
rpcHandlers
|
|
} = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
const timeout = new Promise<'timeout'>((resolve) => {
|
|
setTimeout(() => resolve('timeout'), 500);
|
|
});
|
|
const result = await Promise.race([running, timeout]);
|
|
if (result === 'timeout') {
|
|
await rpcHandlers.get('switch')?.({});
|
|
await running;
|
|
}
|
|
|
|
expect(result).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'message',
|
|
message: 'done after retry'
|
|
}));
|
|
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('ignores a stale same-thread failure after retry has started', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.emitStaleTaskFailedAfterRetry = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('ignores a stale thread-status failure after retry has started', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.emitStaleThreadStatusFailureAfterRetry = true;
|
|
const { session } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('ignores a first-turn completion while the second retry is running', async () => {
|
|
harness.remainingThreadSystemErrors = 2;
|
|
harness.emitFirstTurnTaskCompleteAfterSecondRetry = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1', 'thread-1']);
|
|
expect(sessionEvents.filter((event) => event.type === 'ready')).toHaveLength(1);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('still retries a generic systemError when an empty failed turn completion confirms it', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.emitFailedCompletionAfterThreadSystemError = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Codex thread entered systemError; retrying same conversation (1/3)'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not retry when a generic systemError is followed by a cyber-policy block', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.emitCyberPolicyAfterThreadSystemError = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
const failureMessages = sessionEvents.filter((event) => event.type === 'message');
|
|
expect(failureMessages).toHaveLength(1);
|
|
expect(failureMessages[0]?.message).toContain('This content was flagged for possible cybersecurity risk.');
|
|
expect(failureMessages[0]?.message).toContain('https://openai.com/form/enterprise-trusted-access-for-cyber/');
|
|
expect(failureMessages[0]?.message).toContain('https://help.openai.com/en/articles/20001326');
|
|
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not retry an explicitly non-retryable error even when its text is retryable', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
});
|
|
|
|
harness.dispatchNotification?.('error', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
error: { message: 'Selected model is at capacity' },
|
|
willRetry: false
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Selected model is at capacity'
|
|
});
|
|
expect(sessionEvents.some((event) => String(event.message ?? '').includes('retrying same conversation'))).toBe(false);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('retries a safety-buffered turn with the offered faster model only after user opt-in', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = 'gpt-5.4-mini';
|
|
harness.emitTurnAbortedOnInterrupt = true;
|
|
const {
|
|
session,
|
|
rpcHandlers,
|
|
getAgentState,
|
|
getModel,
|
|
getModelReasoningEffort
|
|
} = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(Object.values(getAgentState().requests)).toContainEqual(expect.objectContaining({
|
|
tool: 'request_user_input'
|
|
}));
|
|
});
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(harness.interruptedTurns).toEqual([]);
|
|
expect(harness.rollbackCalls).toEqual([]);
|
|
|
|
const requestId = Object.keys(getAgentState().requests)[0];
|
|
await rpcHandlers.get('permission')?.({
|
|
id: requestId,
|
|
approved: true,
|
|
answers: {
|
|
safety_buffering_action: {
|
|
answers: ['Retry with a faster model']
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
|
|
expect(harness.rollbackCalls).toEqual([{ threadId: 'thread-1', numTurns: 1 }]);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(harness.startTurnParams[1]).toMatchObject({
|
|
threadId: 'thread-1',
|
|
effort: 'low',
|
|
input: [{ type: 'text', text: 'first message' }],
|
|
collaborationMode: {
|
|
mode: 'default',
|
|
settings: {
|
|
model: 'gpt-5.4-mini',
|
|
reasoning_effort: 'low'
|
|
}
|
|
}
|
|
});
|
|
expect(getModel()).toBe('gpt-5.4-mini');
|
|
expect(getModelReasoningEffort()).toBe('low');
|
|
});
|
|
|
|
it('keeps the original safety-buffered turn running when the user dismisses the retry', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = 'gpt-5.4-mini';
|
|
const { session, rpcHandlers, getAgentState } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
|
|
const requestId = Object.keys(getAgentState().requests)[0];
|
|
await rpcHandlers.get('permission')?.({
|
|
id: requestId,
|
|
approved: true,
|
|
answers: {
|
|
safety_buffering_action: {
|
|
answers: ['Keep waiting']
|
|
}
|
|
}
|
|
});
|
|
|
|
expect(harness.interruptedTurns).toEqual([]);
|
|
expect(harness.rollbackCalls).toEqual([]);
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
harness.dispatchNotification?.('model/safetyBuffering/updated', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: true,
|
|
fasterModel: 'gpt-5.4-mini'
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(getAgentState().requests).toEqual({});
|
|
|
|
harness.dispatchNotification?.('model/safetyBuffering/updated', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: false,
|
|
fasterModel: 'gpt-5.4-mini'
|
|
});
|
|
harness.dispatchNotification?.('model/safetyBuffering/updated', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: true,
|
|
fasterModel: 'gpt-5.4-mini'
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
await expect(running).resolves.toBe('exit');
|
|
});
|
|
|
|
it('dismisses safety-buffering choices when hidden or when agent output starts', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = 'gpt-5.4-mini';
|
|
const { session, getAgentState } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
const hiddenRequestId = Object.keys(getAgentState().requests)[0];
|
|
|
|
harness.dispatchNotification?.('model/safetyBuffering/updated', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: false,
|
|
fasterModel: 'gpt-5.4-mini'
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(getAgentState().requests).toEqual({});
|
|
expect(getAgentState().completedRequests[hiddenRequestId]).toMatchObject({
|
|
status: 'canceled',
|
|
reason: 'Safety buffering ended'
|
|
});
|
|
});
|
|
|
|
harness.dispatchNotification?.('model/safetyBuffering/updated', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
model: 'gpt-5.4',
|
|
useCases: ['cyber'],
|
|
reasons: ['review'],
|
|
showBufferingUi: true,
|
|
fasterModel: 'gpt-5.4-mini'
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
const outputRequestId = Object.keys(getAgentState().requests)[0];
|
|
expect(outputRequestId).not.toBe(hiddenRequestId);
|
|
|
|
harness.dispatchNotification?.('item/agentMessage/delta', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
itemId: 'message-1',
|
|
delta: 'Visible response'
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(getAgentState().requests).toEqual({});
|
|
expect(getAgentState().completedRequests[outputRequestId]).toMatchObject({
|
|
status: 'canceled',
|
|
reason: 'Agent output started'
|
|
});
|
|
});
|
|
expect(harness.interruptedTurns).toEqual([]);
|
|
expect(harness.rollbackCalls).toEqual([]);
|
|
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
await expect(running).resolves.toBe('exit');
|
|
});
|
|
|
|
it('surfaces safety buffering without offering retry when fasterModel is null', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = null;
|
|
const { session, sessionEvents, getAgentState } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Codex is taking extra time to review this request. Learn more: https://help.openai.com/en/articles/20001326'
|
|
});
|
|
});
|
|
expect(getAgentState().requests).toEqual({});
|
|
expect(harness.interruptedTurns).toEqual([]);
|
|
expect(harness.rollbackCalls).toEqual([]);
|
|
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
await expect(running).resolves.toBe('exit');
|
|
});
|
|
|
|
it('does not replay a safety-buffered turn when rollback is unavailable', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = 'gpt-5.4-mini';
|
|
harness.emitTurnAbortedOnInterrupt = true;
|
|
harness.rollbackErrors.push(new Error('thread/rollback is unsupported'));
|
|
const { session, sessionEvents, rpcHandlers, getAgentState } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
|
|
const requestId = Object.keys(getAgentState().requests)[0];
|
|
await rpcHandlers.get('permission')?.({
|
|
id: requestId,
|
|
approved: true,
|
|
answers: {
|
|
safety_buffering_action: {
|
|
answers: ['Retry with a faster model']
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
expect(harness.rollbackCalls).toEqual([{ threadId: 'thread-1', numTurns: 1 }]);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Failed to retry with a faster model: thread/rollback is unsupported'
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({ type: 'ready' });
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('keeps the original turn running when safety-buffering interrupt fails', async () => {
|
|
harness.emitSafetyBuffering = true;
|
|
harness.safetyBufferingFasterModel = 'gpt-5.4-mini';
|
|
harness.interruptErrors.push(new Error('turn/interrupt failed'));
|
|
const { session, sessionEvents, rpcHandlers, getAgentState } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(Object.keys(getAgentState().requests)).toHaveLength(1);
|
|
});
|
|
|
|
const requestId = Object.keys(getAgentState().requests)[0];
|
|
await rpcHandlers.get('permission')?.({
|
|
id: requestId,
|
|
approved: true,
|
|
answers: {
|
|
safety_buffering_action: {
|
|
answers: ['Retry with a faster model']
|
|
}
|
|
}
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Failed to retry with a faster model: turn/interrupt failed'
|
|
});
|
|
});
|
|
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
expect(harness.rollbackCalls).toEqual([]);
|
|
expect(session.thinking).toBe(true);
|
|
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
await expect(running).resolves.toBe('exit');
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('surfaces model reroute and Trusted Access verification notices', async () => {
|
|
harness.emitModelSafetyNotices = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Codex rerouted the model from gpt-5.4 to gpt-5.4-codex (highRiskCyberActivity).'
|
|
});
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Your conversations have multiple flags for possible cybersecurity risk. Responses may take longer because extra safety checks are on. To get authorized for security work, join [Trusted Access for Cyber](https://chatgpt.com/cyber).'
|
|
});
|
|
});
|
|
|
|
it('compacts the same thread before retrying context-window overflow', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.nextThreadSystemErrorMessage = "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.";
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: "Task failed: Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('retries asynchronous thread-level systemError notifications on the same thread', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.deferThreadStatusNotifications = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.resumeThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Codex thread entered systemError'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('compacts before retrying asynchronous context-window overflow notifications', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.deferThreadStatusNotifications = true;
|
|
harness.nextThreadSystemErrorMessage = "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.";
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: "Task failed: Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not create a new thread when same-conversation compact fails', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
harness.nextThreadSystemErrorMessage = "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.";
|
|
harness.failNextCompact = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: context window overflow and same-conversation compact failed'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('keeps using the old thread for later messages after same-thread retries are exhausted', async () => {
|
|
harness.remainingThreadSystemErrors = 4;
|
|
const { session } = createSessionStub(['first message', 'second message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.resumeThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1', 'thread-1', 'thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message', 'first message', 'first message', 'second message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not create a new thread when an existing conversation cannot be resumed', async () => {
|
|
harness.failResumeThreadIds = ['thread-old'];
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
session.sessionId = 'thread-old';
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.resumeThreadIds).toEqual(['thread-old']);
|
|
expect(harness.resumeThreadParams).toHaveLength(1);
|
|
expect(harness.resumeThreadParams[0]?.threadSource).toBeUndefined();
|
|
expect(harness.startThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual([]);
|
|
expect(session.sessionId).toBe('thread-old');
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Task failed: Codex conversation thread-old could not be resumed; no new conversation was created. Reason: resume failed'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not start a fresh thread for the next queued message after thread-level systemError', async () => {
|
|
harness.remainingThreadSystemErrors = 1;
|
|
const { session } = createSessionStub(['first message', 'second message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.resumeThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-1', 'thread-1']);
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'first message', 'second message']);
|
|
expect(session.sessionId).toBe('thread-1');
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('surfaces Codex bash stdout instead of duplicating raw output json', async () => {
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'cmd-1',
|
|
output: expect.objectContaining({
|
|
command: 'echo ok',
|
|
cwd: '/tmp/hapi-update',
|
|
stdout: 'ok\n',
|
|
exit_code: 0
|
|
})
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'cmd-1',
|
|
output: expect.objectContaining({
|
|
output: 'ok\n'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('routes child thread messages into agent-run trace while keeping them out of the parent timeline', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
const { session, codexMessages, summaryMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'message',
|
|
message: 'child output should stay hidden'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
callId: 'child-cmd-1'
|
|
}));
|
|
expect(summaryMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'summary',
|
|
summary: 'Child Title'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'wait_agent',
|
|
callId: 'wait-child'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'message',
|
|
message: 'child output should stay hidden'
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'tool-call',
|
|
callId: 'child-cmd-1'
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
activity: 'Running command: echo child',
|
|
activityKind: 'running-command'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
summary: 'Child Title'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'completed',
|
|
result: 'child output should stay hidden',
|
|
activity: 'Completed: child output should stay hidden',
|
|
activityKind: 'completed'
|
|
}));
|
|
});
|
|
|
|
it('keeps the child final message as result when wait_agent only reports done', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildDoneStatusWithoutMessage = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const completedUpdates = codexMessages.filter((message) => {
|
|
const record = message as Record<string, unknown>;
|
|
return record.type === 'agent-run-update'
|
|
&& record.agentId === 'child-thread'
|
|
&& record.status === 'completed';
|
|
}) as Array<Record<string, unknown>>;
|
|
|
|
expect(completedUpdates).toContainEqual(expect.objectContaining({
|
|
result: 'child output should stay hidden',
|
|
activity: 'Completed: child output should stay hidden'
|
|
}));
|
|
expect(completedUpdates).not.toContainEqual(expect.objectContaining({
|
|
result: expect.objectContaining({
|
|
status: 'done'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('fills wait_agent done without message from the latest child message', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildDoneStatusWithoutMessage = true;
|
|
harness.suppressChildTaskCompleteEvent = true;
|
|
harness.emitSecondChildMessage = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const completedUpdates = codexMessages.filter((message) => {
|
|
const record = message as Record<string, unknown>;
|
|
return record.type === 'agent-run-update'
|
|
&& record.agentId === 'child-thread'
|
|
&& record.status === 'completed';
|
|
}) as Array<Record<string, unknown>>;
|
|
const lastCompleted = completedUpdates.at(-1);
|
|
|
|
expect(lastCompleted).toEqual(expect.objectContaining({
|
|
result: 'final child output should win',
|
|
activity: 'Completed: final child output should win'
|
|
}));
|
|
});
|
|
|
|
it('preserves wait_agent structured output when status message is empty', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildWaitStructuredOutput = true;
|
|
harness.suppressChildTaskCompleteEvent = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const completedUpdates = codexMessages.filter((message) => {
|
|
const record = message as Record<string, unknown>;
|
|
return record.type === 'agent-run-update'
|
|
&& record.agentId === 'child-thread'
|
|
&& record.status === 'completed';
|
|
}) as Array<Record<string, unknown>>;
|
|
const lastCompleted = completedUpdates.at(-1);
|
|
|
|
expect(lastCompleted).toEqual(expect.objectContaining({
|
|
result: { value: 42 },
|
|
activity: 'Completed: {"value":42}'
|
|
}));
|
|
});
|
|
|
|
it('does not regress a completed child agent to running when message arrives late', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildDoneStatusWithoutMessage = true;
|
|
harness.emitChildTaskCompleteBeforeMessage = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const terminalIndex = codexMessages.findIndex((message) => {
|
|
const record = message as Record<string, unknown>;
|
|
return record.type === 'agent-run-update'
|
|
&& record.agentId === 'child-thread'
|
|
&& record.status === 'completed';
|
|
});
|
|
expect(terminalIndex).toBeGreaterThanOrEqual(0);
|
|
|
|
const laterUpdates = codexMessages.slice(terminalIndex + 1).filter((message) => {
|
|
const record = message as Record<string, unknown>;
|
|
return record.type === 'agent-run-update'
|
|
&& record.agentId === 'child-thread';
|
|
}) as Array<Record<string, unknown>>;
|
|
|
|
expect(laterUpdates).not.toContainEqual(expect.objectContaining({
|
|
status: 'running'
|
|
}));
|
|
expect(laterUpdates).toContainEqual(expect.objectContaining({
|
|
status: 'completed',
|
|
result: 'child output should stay hidden',
|
|
activity: 'Completed: child output should stay hidden'
|
|
}));
|
|
});
|
|
|
|
it('surfaces send_input failures on the target child agent card', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitParentSendInputFailure = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'running',
|
|
statusText: 'Sending input',
|
|
activity: 'Sending input',
|
|
activityKind: 'send_input'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'failed',
|
|
statusText: 'Send input failed',
|
|
activity: 'Send input failed: send failed',
|
|
activityKind: 'failed',
|
|
error: expect.objectContaining({
|
|
error: 'send failed'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('updates the target child agent card when resume_agent completes', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitParentResumeSuccess = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'running',
|
|
statusText: 'Resuming agent',
|
|
activity: 'Resuming agent',
|
|
activityKind: 'resume_agent'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'running',
|
|
statusText: 'Resumed',
|
|
activity: 'Resumed',
|
|
activityKind: 'resume_agent',
|
|
result: expect.objectContaining({
|
|
status: 'completed',
|
|
targets: ['child-thread']
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('does not regress a terminal child after resume_agent when a late command starts', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitParentResumeSuccess = true;
|
|
harness.emitLateChildCommandAfterParentTool = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'tool-call',
|
|
callId: 'late-child-cmd'
|
|
})
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
activity: 'Running command: echo late',
|
|
activityKind: 'running-command',
|
|
status: 'running'
|
|
}));
|
|
});
|
|
|
|
it('throttles child agent reasoning activity updates instead of emitting one per delta', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildReasoningBurst = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const thinkingUpdates = codexMessages.filter((message): message is Record<string, unknown> => {
|
|
return typeof message === 'object'
|
|
&& message !== null
|
|
&& (message as Record<string, unknown>).type === 'agent-run-update'
|
|
&& (message as Record<string, unknown>).agentId === 'child-thread'
|
|
&& (message as Record<string, unknown>).activityKind === 'thinking';
|
|
});
|
|
|
|
expect(thinkingUpdates.length).toBeLessThan(20);
|
|
expect(thinkingUpdates.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('keeps child usage and compact events out of the parent context stream', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildUsageEvents = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'token_count',
|
|
thread_id: 'child-thread'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'token_count',
|
|
info: expect.objectContaining({
|
|
last_token_usage: expect.objectContaining({
|
|
input_tokens: 999
|
|
})
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'context_compacted'
|
|
})
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'context_compacted',
|
|
thread_id: 'child-thread'
|
|
}));
|
|
});
|
|
|
|
it('keeps child goal events out of the parent goal stream', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildGoalEvent = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'thread_goal_updated',
|
|
thread_id: 'child-thread'
|
|
}));
|
|
});
|
|
|
|
it('suppresses duplicate parent goal updates that only change runtime counters', async () => {
|
|
harness.emitParentGoalDuplicateEvents = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
const goalMessages = codexMessages.filter((message): message is Record<string, unknown> => {
|
|
return Boolean(message && typeof message === 'object' && (message as Record<string, unknown>).type === 'thread_goal_updated');
|
|
});
|
|
expect(goalMessages).toHaveLength(2);
|
|
expect(goalMessages).toEqual([
|
|
expect.objectContaining({
|
|
thread_id: 'thread-1',
|
|
goal: expect.objectContaining({
|
|
status: 'active',
|
|
updatedAt: 2
|
|
})
|
|
}),
|
|
expect.objectContaining({
|
|
thread_id: 'thread-1',
|
|
goal: expect.objectContaining({
|
|
status: 'paused',
|
|
updatedAt: 6
|
|
})
|
|
})
|
|
]);
|
|
});
|
|
|
|
it('suppresses duplicate goal events from repeated show commands', async () => {
|
|
const { session, codexMessages } = createSessionStub([
|
|
'/goal keep benchmark work moving',
|
|
'/goal'
|
|
]);
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(harness.goalSetCalls).toHaveLength(1);
|
|
expect(harness.goalGetCalls).toEqual([{ threadId: 'thread-1' }]);
|
|
const goalMessages = codexMessages.filter((message): message is Record<string, unknown> => {
|
|
return Boolean(message && typeof message === 'object' && (message as Record<string, unknown>).type === 'thread_goal_updated');
|
|
});
|
|
expect(goalMessages).toHaveLength(1);
|
|
expect(goalMessages[0]).toEqual(expect.objectContaining({
|
|
thread_id: 'thread-1',
|
|
goal: expect.objectContaining({
|
|
objective: 'keep benchmark work moving',
|
|
status: 'active'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('marks parent usage and compact events with parent scope', async () => {
|
|
harness.emitParentUsageEvents = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'token_count',
|
|
flavor: 'codex',
|
|
thread_id: 'thread-1',
|
|
usageSchema: 'hapi.usage.v1',
|
|
inputTokenSemantics: 'includes-cache',
|
|
scope_role: 'parent',
|
|
scope: expect.objectContaining({
|
|
role: 'parent',
|
|
thread_id: 'thread-1'
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'context_compacted',
|
|
thread_id: 'thread-1',
|
|
scope_role: 'parent',
|
|
scope: expect.objectContaining({
|
|
role: 'parent',
|
|
thread_id: 'thread-1'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('marks child agents failed when they attempt to start nested agents', async () => {
|
|
harness.emitChildThreadEvents = true;
|
|
harness.emitChildNestedAgentTool = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'spawn_agent',
|
|
callId: 'nested-spawn'
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace',
|
|
agentId: 'child-thread',
|
|
message: expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'nested-spawn',
|
|
is_error: true,
|
|
output: 'Nested agent calls are disabled for child agents.'
|
|
})
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
status: 'failed',
|
|
activity: 'Failed: Nested agent calls are disabled for child agents.',
|
|
activityKind: 'failed'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'grandchild-thread'
|
|
}));
|
|
});
|
|
|
|
it('marks spawn_agent cards failed when Codex returns no agent id', async () => {
|
|
harness.emitParentSpawnFailureWithoutAgentId = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-start',
|
|
cardId: 'failed-spawn',
|
|
status: 'starting'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn',
|
|
status: 'failed',
|
|
statusText: 'Failed to start',
|
|
activityKind: 'failed',
|
|
error: expect.objectContaining({
|
|
status: 'failed',
|
|
error: 'invalid spawn arguments'
|
|
})
|
|
}));
|
|
});
|
|
|
|
it('forwards shared MultiAgent V2 tools as direct tool cards', async () => {
|
|
harness.emitParentV2AgentTools = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'spawn_agent',
|
|
callId: 'v2-spawn',
|
|
input: { task_name: 'review', message: 'Review this' }
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'v2-spawn',
|
|
output: '{"task_name":"/root/review","nickname":"Reviewer"}'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'wait_agent',
|
|
callId: 'v2-wait',
|
|
input: { timeout_ms: 10_000 }
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'v2-wait',
|
|
output: '{"message":"Wait timed out.","timed_out":true}'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-start',
|
|
cardId: 'v2-spawn'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:v2-spawn'
|
|
}));
|
|
});
|
|
|
|
it('marks pending spawn_agent cards failed with the Codex router argument error from stderr', async () => {
|
|
harness.emitParentSpawnStartWithoutEnd = true;
|
|
harness.emitParentSpawnRouterStderrError = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn',
|
|
status: 'failed',
|
|
statusText: 'Failed to start',
|
|
activityKind: 'failed',
|
|
error: 'Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' +
|
|
'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn',
|
|
error: 'spawn_agent did not return an agent id before the Codex session ended'
|
|
}));
|
|
});
|
|
|
|
it('links a lone pending spawn_agent card from the child task_started event', async () => {
|
|
harness.emitParentSpawnStartWithoutEnd = true;
|
|
harness.emitChildTaskStartedAfterParentSpawnStart = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
cardId: 'failed-spawn',
|
|
status: 'running',
|
|
activity: 'Started',
|
|
activityKind: 'running'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn'
|
|
}));
|
|
});
|
|
|
|
it('does not guess a child task_started card when multiple spawn_agent starts are pending', async () => {
|
|
harness.emitParentSpawnStartWithoutEnd = true;
|
|
harness.emitSecondParentSpawnStartWithoutEnd = true;
|
|
harness.emitChildTaskStartedAfterParentSpawnStart = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
cardId: 'failed-spawn'
|
|
}));
|
|
expect(codexMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'child-thread',
|
|
cardId: 'second-spawn'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:second-spawn',
|
|
cardId: 'second-spawn'
|
|
}));
|
|
});
|
|
|
|
it('marks pending spawn_agent cards failed when the session ends before a result', async () => {
|
|
harness.emitParentSpawnStartWithoutEnd = true;
|
|
const { session, codexMessages } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-start',
|
|
cardId: 'failed-spawn',
|
|
status: 'starting'
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-update',
|
|
agentId: 'spawn-error:failed-spawn',
|
|
cardId: 'failed-spawn',
|
|
status: 'failed',
|
|
statusText: 'Failed to start',
|
|
activityKind: 'failed',
|
|
error: 'spawn_agent did not return an agent id before the Codex session ended'
|
|
}));
|
|
});
|
|
|
|
it('applies parent-thread hapi change_title after disabling MCP-side title writes', async () => {
|
|
harness.emitParentTitleChange = true;
|
|
const { session, codexMessages, summaryMessages, getMetadata } = createSessionStub();
|
|
|
|
await codexRemoteLauncher(session as never);
|
|
|
|
expect(harness.bridgeOptions).toEqual([{ emitTitleSummary: false }]);
|
|
expect(summaryMessages).toEqual([]);
|
|
expect(getMetadata().name).toBe('Parent Title');
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call',
|
|
name: 'mcp__hapi__change_title',
|
|
callId: 'title-parent',
|
|
input: { title: 'Parent Title' }
|
|
}));
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'tool-call-result',
|
|
callId: 'title-parent',
|
|
is_error: false
|
|
}));
|
|
});
|
|
|
|
it('clears codex thread state without starting a turn', async () => {
|
|
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(resetThreadCalls).toEqual(['none']);
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Context was reset'
|
|
});
|
|
expect(session.sessionId).toBe('thread-1');
|
|
});
|
|
|
|
it('interrupts an in-flight turn before clearing codex thread state', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['first message', '/clear']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
|
|
expect(resetThreadCalls).toEqual(['thread-1']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Context was reset'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('interrupts active child agent turns before clearing codex thread state', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.emitRunningChildTurnBeforeSuppressedParent = true;
|
|
const { session, resetThreadCalls } = createSessionStub(['first message', '/clear']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.interruptedTurns).toEqual([
|
|
{ threadId: 'thread-1', turnId: 'turn-1' },
|
|
{ threadId: 'child-thread', turnId: 'child-turn' }
|
|
]);
|
|
expect(resetThreadCalls).toEqual(['thread-1']);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('interrupts active child agent turns when the abort RPC is invoked', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.emitRunningChildTurnBeforeSuppressedParent = true;
|
|
harness.emitTurnAbortedOnInterrupt = true;
|
|
const { session, rpcHandlers } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(rpcHandlers.has('abort')).toBe(true);
|
|
});
|
|
|
|
await rpcHandlers.get('abort')?.({});
|
|
const exitReason = await running;
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.interruptedTurns).toEqual([
|
|
{ threadId: 'thread-1', turnId: 'turn-1' },
|
|
{ threadId: 'child-thread', turnId: 'child-turn' }
|
|
]);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('does not interrupt completed child agent turns when clearing codex thread state', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.emitCompletedChildTurnBeforeSuppressedParent = true;
|
|
const { session, resetThreadCalls } = createSessionStub(['first message', '/clear']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.interruptedTurns).toEqual([
|
|
{ threadId: 'thread-1', turnId: 'turn-1' }
|
|
]);
|
|
expect(resetThreadCalls).toEqual(['thread-1']);
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('compacts the current thread without starting a turn', async () => {
|
|
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction started'
|
|
});
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction completed'
|
|
});
|
|
});
|
|
|
|
it('does not start the next turn until manual compaction finishes', async () => {
|
|
harness.deferCompactCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub([
|
|
'first message',
|
|
'/compact',
|
|
'after compact'
|
|
], createMode(), true);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
});
|
|
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
expect(sessionEvents).not.toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction completed'
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'compact-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'compact-1', status: 'completed' }
|
|
});
|
|
|
|
const exitReason = await running;
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startTurnMessages).toEqual(['first message', 'after compact']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction completed'
|
|
});
|
|
});
|
|
|
|
it('interrupts an in-flight turn before compacting the current thread', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual(['thread-1']);
|
|
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
|
|
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction completed'
|
|
});
|
|
expect(session.thinking).toBe(false);
|
|
});
|
|
|
|
it('reports nothing to compact when no codex thread exists', async () => {
|
|
const { session, sessionEvents } = createSessionStub(['/compact']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual([]);
|
|
expect(harness.compactThreadIds).toEqual([]);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: 'Nothing to compact'
|
|
});
|
|
});
|
|
|
|
it('rejects argument-bearing codex slash commands without starting a turn', async () => {
|
|
const { session, sessionEvents } = createSessionStub(['/compact now']);
|
|
|
|
const exitReason = await codexRemoteLauncher(session as never);
|
|
|
|
expect(exitReason).toBe('exit');
|
|
expect(harness.startThreadIds).toEqual([]);
|
|
expect(harness.startTurnThreadIds).toEqual([]);
|
|
expect(harness.compactThreadIds).toEqual([]);
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '/compact does not accept arguments'
|
|
});
|
|
});
|
|
|
|
it('announces automatic compaction when it starts, before it completes', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/started', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction started'
|
|
});
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
const startCount = sessionEvents.filter(
|
|
(event) => event.type === 'message' && event.message === '📦 Compaction started'
|
|
).length;
|
|
expect(startCount).toBe(1);
|
|
});
|
|
|
|
it('dedups duplicate automatic compaction starts and keeps foreign-thread starts out of the parent stream', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents, codexMessages } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
});
|
|
|
|
const startParams = {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
};
|
|
harness.dispatchNotification?.('item/started', startParams);
|
|
harness.dispatchNotification?.('item/started', startParams);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction started'
|
|
});
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/started', {
|
|
threadId: 'foreign-thread',
|
|
turnId: 'foreign-turn',
|
|
item: { id: 'compact-item-foreign', type: 'contextCompaction' }
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
const startCount = sessionEvents.filter(
|
|
(event) => event.type === 'message' && event.message === '📦 Compaction started'
|
|
).length;
|
|
expect(startCount).toBe(1);
|
|
expect(codexMessages).toContainEqual(expect.objectContaining({
|
|
type: 'agent-run-trace'
|
|
}));
|
|
});
|
|
|
|
it('announces successive compactions in one turn without suppression', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.startTurnMessages).toEqual(['first message']);
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/started', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-1', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('item/started', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-2', type: 'contextCompaction' }
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
const startCount = sessionEvents.filter(
|
|
(event) => event.type === 'message' && event.message === '📦 Compaction started'
|
|
).length;
|
|
expect(startCount).toBe(2);
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
item: { id: 'compact-item-2', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'turn-1', status: 'completed' }
|
|
});
|
|
|
|
await expect(running).resolves.toBe('exit');
|
|
});
|
|
|
|
it('suppresses automatic compaction start notification while manual /compact is in flight', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.deferCompactCompletion = true;
|
|
const { session, sessionEvents, rpcHandlers } = createSessionStub(
|
|
['first message', '/compact'],
|
|
createMode(),
|
|
false,
|
|
false
|
|
);
|
|
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => {
|
|
expect(harness.compactThreadIds).toEqual(['thread-1']);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(sessionEvents).toContainEqual({
|
|
type: 'message',
|
|
message: '📦 Compaction started'
|
|
});
|
|
});
|
|
const startCountBefore = sessionEvents.filter(
|
|
(event) => event.type === 'message' && event.message === '📦 Compaction started'
|
|
).length;
|
|
|
|
harness.dispatchNotification?.('item/started', {
|
|
threadId: 'thread-1',
|
|
turnId: 'compact-turn',
|
|
item: { id: 'compact-item-manual', type: 'contextCompaction' }
|
|
});
|
|
|
|
harness.dispatchNotification?.('item/completed', {
|
|
threadId: 'thread-1',
|
|
turnId: 'compact-turn',
|
|
item: { id: 'compact-item-manual', type: 'contextCompaction' }
|
|
});
|
|
harness.dispatchNotification?.('turn/completed', {
|
|
threadId: 'thread-1',
|
|
turn: { id: 'compact-turn', status: 'completed' }
|
|
});
|
|
|
|
await rpcHandlers.get('switch')?.({});
|
|
await expect(running).resolves.toBe('switch');
|
|
const startCount = sessionEvents.filter(
|
|
(event) => event.type === 'message' && event.message === '📦 Compaction started'
|
|
).length;
|
|
expect(startCount).toBe(startCountBefore);
|
|
});
|
|
|
|
it('binds deferred manual compact to its actual turn, ignoring a stale compaction start', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
harness.deferCompactCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message', '/compact'], createMode(), true);
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => { expect(harness.compactThreadIds).toEqual(['thread-1']); });
|
|
harness.dispatchNotification?.('item/started', { threadId: 'thread-1', turnId: 'stale-turn', item: { id: 'stale-item', type: 'contextCompaction' } });
|
|
harness.dispatchNotification?.('turn/started', { threadId: 'thread-1', turn: { id: 'compact-actual' } });
|
|
harness.dispatchNotification?.('item/completed', { threadId: 'thread-1', turnId: 'compact-actual', item: { id: 'compact-item-1', type: 'contextCompaction' } });
|
|
harness.dispatchNotification?.('turn/completed', { threadId: 'thread-1', turn: { id: 'compact-actual', status: 'completed' } });
|
|
const exitReason = await running;
|
|
expect(exitReason).toBe('exit');
|
|
expect(sessionEvents).toContainEqual({ type: 'message', message: '📦 Compaction completed' });
|
|
});
|
|
|
|
it('drops unscoped compaction starts while child agents are active', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, sessionEvents } = createSessionStub(['first message']);
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => { expect(harness.startTurnMessages).toEqual(['first message']); });
|
|
harness.dispatchNotification?.('item/started', { item: { id: 'call-spawn', type: 'collabAgentToolCall', tool: 'spawnAgent', prompt: 'x', receiverThreadIds: [] } });
|
|
harness.dispatchNotification?.('item/started', { item: { id: 'compact-item-1', type: 'contextCompaction' } });
|
|
harness.dispatchNotification?.('turn/completed', { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed' } });
|
|
await expect(running).resolves.toBe('exit');
|
|
expect(sessionEvents.filter((e) => e.type === 'message' && e.message === '📦 Compaction started')).toHaveLength(0);
|
|
});
|
|
|
|
it('emits child compaction trace with exact agent id, type and statusText', async () => {
|
|
harness.suppressTurnCompletion = true;
|
|
const { session, codexMessages } = createSessionStub(['first message']);
|
|
const running = codexRemoteLauncher(session as never);
|
|
await vi.waitFor(() => { expect(harness.startTurnMessages).toEqual(['first message']); });
|
|
harness.dispatchNotification?.('item/started', { threadId: 'child-1', turnId: 'cturn-1', item: { id: 'compact-child-1', type: 'contextCompaction' } });
|
|
harness.dispatchNotification?.('turn/completed', { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed' } });
|
|
await expect(running).resolves.toBe('exit');
|
|
const traces = codexMessages.filter((m) => (m as Record<string, unknown>).type === 'agent-run-trace');
|
|
expect(traces).toContainEqual(expect.objectContaining({ agentId: 'child-1', message: expect.objectContaining({ type: 'compaction_started', statusText: '📦 Compaction started' }) }));
|
|
});
|
|
});
|