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 Promise | unknown>(), initializeCalls: [] as unknown[], configReadCalls: [] as unknown[], configReadResponse: { config: {} } as { config: Record }, 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>, resumeThreadIds: [] as string[], resumeThreadParams: [] as Array>, startTurnThreadIds: [] as string[], startTurnParams: [] as Array>, 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 | 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>, steerDispatchError: null as Error | null, steerCompletionError: null as Error | null, readThreadParams: [] as Array>, readThreadError: null as Error | null, readThreadResponse: { thread: { turns: [] as unknown[] } } as { thread: { turns?: Array<{ items?: Array> }> } }, 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 {} 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 }> { 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 { harness.listCollaborationModeCalls += 1; if (harness.failListCollaborationModes) { throw new Error('collaborationMode/list failed'); } return harness.collaborationModeResponse; } async listSkills(params: unknown): Promise { harness.listSkillsCalls.push(params); return harness.skillsListResponse; } async setExperimentalFeatureEnablement(params: unknown): Promise { harness.setFeatureEnablementCalls.push(params); if (harness.failSetFeatureEnablement) { throw new Error('unsupported feature enablement'); } return params; } registerRequestHandler(method: string, handler: (params: unknown) => Promise | unknown): void { harness.registerRequestCalls.push(method); harness.requestHandlers.set(method, handler); } async startThread(params?: Record): 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): 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> { 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 }> { 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 | null }> { harness.goalGetCalls.push(params ?? {}); return { goal: harness.goal }; } async steerTurn(params?: { threadId?: string; input?: unknown[]; expectedTurnId?: string; clientUserMessageId?: string }): Promise<{ dispatched: Promise; completed: Promise; }> { harness.steerTurnParams.push((params ?? {}) as Record); 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 { harness.readThreadParams.push((params ?? {}) as Record); 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); 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> { 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 {} } return { CodexAppServerClient: MockCodexAppServerClient, INDETERMINATE_SYMBOL, isIndeterminateError: (error: unknown) => Boolean( error && (error as Record)[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; completedRequests: Record; }; 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((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> = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; const collaborationModes: Array = []; 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 = { path: '/tmp/hapi-update', host: 'localhost', name: 'issue-triage-#54' }; const rpcHandlers = new Map unknown>(); const client = { rpcHandlerManager: { registerHandler(method: string, handler: (params: unknown) => unknown) { rpcHandlers.set(method, handler); } }, updateMetadata(handler: (current: Record) => Record) { 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 | 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 | 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; return record.type === 'agent-run-update' && record.agentId === 'child-thread' && record.status === 'completed'; }) as Array>; 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; return record.type === 'agent-run-update' && record.agentId === 'child-thread' && record.status === 'completed'; }) as Array>; 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; return record.type === 'agent-run-update' && record.agentId === 'child-thread' && record.status === 'completed'; }) as Array>; 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; 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; return record.type === 'agent-run-update' && record.agentId === 'child-thread'; }) as Array>; 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 => { return typeof message === 'object' && message !== null && (message as Record).type === 'agent-run-update' && (message as Record).agentId === 'child-thread' && (message as Record).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 => { return Boolean(message && typeof message === 'object' && (message as Record).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 => { return Boolean(message && typeof message === 'object' && (message as Record).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).type === 'agent-run-trace'); expect(traces).toContainEqual(expect.objectContaining({ agentId: 'child-1', message: expect.objectContaining({ type: 'compaction_started', statusText: '📦 Compaction started' }) })); }); });