Files
hapi/cli/src/codex/utils/appServerEventConverter.test.ts
T

1075 lines
38 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { logger } from '@/ui/logger';
import { AppServerEventConverter } from './appServerEventConverter';
describe('AppServerEventConverter', () => {
it('maps thread/started', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/started', { thread: { id: 'thread-1' } });
expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-1' }]);
});
it('maps thread/resumed', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/resumed', { thread: { id: 'thread-2' } });
expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-2' }]);
});
it('maps thread goal updates and clears', () => {
const converter = new AppServerEventConverter();
const goal = {
threadId: 'thread-1',
objective: 'ship goal support',
status: 'active'
};
expect(converter.handleNotification('thread/goal/updated', {
threadId: 'thread-1',
turnId: 'turn-1',
goal
})).toEqual([{
type: 'thread_goal_updated',
thread_id: 'thread-1',
turn_id: 'turn-1',
goal
}]);
expect(converter.handleNotification('thread/goal/cleared', {
threadId: 'thread-1'
})).toEqual([{
type: 'thread_goal_cleared',
thread_id: 'thread-1'
}]);
});
it('maps thread systemError to a task failure', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/status/changed', {
thread: { id: 'thread-1' },
status: { type: 'systemError' }
});
expect(events).toEqual([{
type: 'task_failed',
thread_id: 'thread-1',
terminal_source: 'thread_status',
error: 'Codex thread entered systemError'
}]);
});
it('maps turn/started and completed statuses', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('turn/started', { turn: { id: 'turn-1' } });
expect(started).toEqual([{ type: 'task_started', turn_id: 'turn-1' }]);
const completed = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Completed' });
expect(completed).toEqual([{ type: 'task_complete', turn_id: 'turn-1' }]);
const interrupted = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Interrupted' });
expect(interrupted).toEqual([{ type: 'turn_aborted', turn_id: 'turn-1' }]);
const failed = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Failed', message: 'boom' });
expect(failed).toEqual([{
type: 'task_failed',
turn_id: 'turn-1',
terminal_source: 'turn_completed',
error: 'boom'
}]);
});
it('accumulates agent message deltas', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('item/agentMessage/delta', { itemId: 'msg-1', delta: 'Hello' });
converter.handleNotification('item/agentMessage/delta', { itemId: 'msg-1', delta: ' world' });
const completed = converter.handleNotification('item/completed', {
item: { id: 'msg-1', type: 'agentMessage' }
});
expect(completed).toEqual([{ type: 'agent_message', message: 'Hello world' }]);
});
it('preserves thread and turn scope on item events', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('item/agentMessage/delta', {
itemId: 'msg-1',
delta: 'child output',
thread_id: 'child-thread',
turn_id: 'child-turn'
});
const completed = converter.handleNotification('item/completed', {
item: { id: 'msg-1', type: 'agentMessage' },
threadId: 'child-thread',
turnId: 'child-turn'
});
expect(completed).toEqual([{
type: 'agent_message',
thread_id: 'child-thread',
turn_id: 'child-turn',
message: 'child output'
}]);
});
it('deduplicates repeated agent message completions for the same item', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('item/agentMessage/delta', { itemId: 'msg-1', delta: 'Hello' });
const first = converter.handleNotification('item/completed', {
item: { id: 'msg-1', type: 'AgentMessage' }
});
const second = converter.handleNotification('item/completed', {
item: { id: 'msg-1', type: 'agentMessage' }
});
expect(first).toEqual([{ type: 'agent_message', message: 'Hello' }]);
expect(second).toEqual([]);
});
it('maps command execution items and output deltas', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('item/started', {
item: { id: 'cmd-1', type: 'commandExecution', command: 'ls' }
});
expect(started).toEqual([{
type: 'exec_command_begin',
call_id: 'cmd-1',
command: 'ls'
}]);
converter.handleNotification('item/commandExecution/outputDelta', { itemId: 'cmd-1', delta: 'ok' });
const completed = converter.handleNotification('item/completed', {
item: { id: 'cmd-1', type: 'commandExecution', exitCode: 0 }
});
expect(completed).toEqual([{
type: 'exec_command_end',
call_id: 'cmd-1',
command: 'ls',
output: 'ok',
exit_code: 0
}]);
});
it('maps MCP tool call items', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('item/started', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
arguments: { title: 'MCP Title' }
}
});
expect(started).toEqual([{
type: 'mcp_tool_call_begin',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
invocation: {
server: 'hapi',
tool: 'change_title',
arguments: { title: 'MCP Title' }
}
}]);
const completed = converter.handleNotification('item/completed', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
result: {
content: [{ type: 'text', text: 'done' }]
}
}
});
expect(completed).toEqual([{
type: 'mcp_tool_call_end',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
result: {
content: [{ type: 'text', text: 'done' }]
}
}]);
});
it('maps MCP tool call item errors', () => {
const converter = new AppServerEventConverter();
const completed = converter.handleNotification('item/completed', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
error: 'boom'
}
});
expect(completed).toEqual([{
type: 'mcp_tool_call_end',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
result: { Err: 'boom' }
}]);
});
it('maps Codex collab spawn agent calls', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('item/started', {
item: {
id: 'call-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
prompt: 'Do side work',
agentType: 'explorer',
forkContext: true,
model: 'gpt-5.5',
reasoningEffort: 'low',
senderThreadId: 'parent-thread',
receiverThreadIds: []
}
});
expect(started).toEqual([{
type: 'codex_tool_call_begin',
call_id: 'call-spawn',
name: 'spawn_agent',
input: {
message: 'Do side work',
agent_type: 'explorer',
fork_context: true,
model: 'gpt-5.5',
reasoning_effort: 'low',
sender_thread_id: 'parent-thread'
}
}]);
const completed = converter.handleNotification('item/completed', {
item: {
id: 'call-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
status: 'completed',
receiverThreadIds: ['agent-1'],
agentsStates: {
'agent-1': { status: 'pendingInit', message: null }
}
}
});
expect(completed).toEqual([{
type: 'codex_tool_call_end',
call_id: 'call-spawn',
name: 'spawn_agent',
output: {
agent_id: 'agent-1',
agentId: 'agent-1',
status: 'completed',
agentsStates: {
'agent-1': { status: 'pendingInit', message: null }
}
},
is_error: false
}]);
});
it('maps Codex collab wait and close outputs for web agent views', () => {
const converter = new AppServerEventConverter();
const waitStarted = converter.handleNotification('item/started', {
item: {
id: 'call-wait',
type: 'collabAgentToolCall',
tool: 'wait',
receiverThreadIds: ['agent-1', 'agent-2']
}
});
expect(waitStarted).toEqual([{
type: 'codex_tool_call_begin',
call_id: 'call-wait',
name: 'wait_agent',
input: {
targets: ['agent-1', 'agent-2']
}
}]);
const waitCompleted = converter.handleNotification('item/completed', {
item: {
id: 'call-wait',
type: 'collabAgentToolCall',
tool: 'wait',
status: 'completed',
receiverThreadIds: ['agent-1'],
agentsStates: {
'agent-1': { status: 'completed', message: '42' },
'agent-2': { status: 'done', message: null },
'agent-3': { status: 'done', result: { text: 'structured result' } },
'agent-4': { status: 'completed', message: '', output: { value: 42 } }
}
}
});
expect(waitCompleted).toEqual([{
type: 'codex_tool_call_end',
call_id: 'call-wait',
name: 'wait_agent',
output: {
status: {
'agent-1': { completed: '42' },
'agent-2': { status: 'completed', message: null },
'agent-3': { status: 'completed', result: { text: 'structured result' } },
'agent-4': { status: 'completed', message: '', output: { value: 42 } }
},
timed_out: false
},
is_error: false
}]);
const closeCompleted = converter.handleNotification('item/completed', {
item: {
id: 'call-close',
type: 'collabAgentToolCall',
tool: 'closeAgent',
status: 'completed',
receiverThreadIds: ['agent-1'],
agentsStates: {
'agent-1': { status: 'completed', message: 'done' }
}
}
});
expect(closeCompleted).toEqual([{
type: 'codex_tool_call_end',
call_id: 'call-close',
name: 'close_agent',
output: {
previous_status: { completed: 'done' },
agent_id: 'agent-1'
},
is_error: false
}]);
});
it('maps raw MultiAgent V2 calls without inventing collab tool variants', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'followup_task',
arguments: JSON.stringify({ target: '/root/review', message: 'Continue with tests' }),
call_id: 'call-followup'
}
})).toEqual([{
thread_id: 'thread-1',
turn_id: 'turn-1',
type: 'codex_tool_call_begin',
call_id: 'call-followup',
name: 'followup_task',
input: {
message: 'Continue with tests',
target: '/root/review'
}
}]);
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call_output',
call_id: 'call-followup',
output: ''
}
})).toEqual([{
thread_id: 'thread-1',
turn_id: 'turn-1',
type: 'codex_tool_call_end',
call_id: 'call-followup',
name: 'followup_task',
output: '',
is_error: false
}]);
expect(converter.handleNotification('item/started', {
item: {
id: 'call-followup',
type: 'collabAgentToolCall',
tool: 'followupTask'
}
})).toEqual([]);
});
it('keeps MultiAgent V1 calls on the collab item stream', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call',
namespace: 'multi_agent_v1',
name: 'spawn_agent',
arguments: JSON.stringify({ message: 'Do side work' }),
call_id: 'call-v1-spawn'
}
})).toEqual([]);
expect(converter.handleNotification('rawResponseItem/completed', {
threadId: 'thread-1',
turnId: 'turn-1',
item: {
type: 'function_call_output',
call_id: 'call-v1-spawn',
output: '{"agent_id":"agent-1","nickname":null}'
}
})).toEqual([]);
expect(converter.handleNotification('item/started', {
item: {
id: 'call-v1-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
prompt: 'Do side work'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v1-spawn',
name: 'spawn_agent'
})]);
});
it('maps shared MultiAgent V2 names only when their V2 argument shape is present', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'spawn_agent',
arguments: JSON.stringify({ task_name: 'review', message: 'Review this' }),
call_id: 'call-v2-spawn'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-spawn',
name: 'spawn_agent',
input: { task_name: 'review', message: 'Review this' }
})]);
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'collaboration',
name: 'wait_agent',
arguments: JSON.stringify({ timeout_ms: 10_000 }),
call_id: 'call-v2-wait'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-wait',
name: 'wait_agent',
input: { timeout_ms: 10_000 }
})]);
expect(converter.handleNotification('rawResponseItem/completed', {
item: {
type: 'function_call',
namespace: 'agents',
name: 'list_agents',
arguments: '{}',
call_id: 'call-v2-custom-namespace'
}
})).toEqual([expect.objectContaining({
type: 'codex_tool_call_begin',
call_id: 'call-v2-custom-namespace',
name: 'list_agents'
})]);
});
it('maps reasoning deltas', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('item/reasoning/textDelta', { itemId: 'r1', delta: 'step' });
expect(events).toEqual([{ type: 'agent_reasoning_delta', delta: 'step' }]);
});
it('dedupes duplicate reasoning deltas', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('item/reasoning/textDelta', { itemId: 'r1', delta: 'Hello ' }))
.toEqual([{ type: 'agent_reasoning_delta', delta: 'Hello ' }]);
expect(converter.handleNotification('item/reasoning/textDelta', { itemId: 'r1', delta: 'Hello ' }))
.toEqual([]);
converter.handleNotification('item/reasoning/textDelta', { itemId: 'r1', delta: 'world' });
const completed = converter.handleNotification('item/completed', {
item: { id: 'r1', type: 'reasoning' }
});
expect(completed).toEqual([{ type: 'agent_reasoning', text: 'Hello world' }]);
});
it('maps reasoning summary deltas', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('item/reasoning/summaryTextDelta', { itemId: 'r1', delta: 'step' });
expect(events).toEqual([{ type: 'agent_reasoning_delta', delta: 'step' }]);
});
it('deduplicates repeated reasoning completions for the same item', () => {
const converter = new AppServerEventConverter();
const first = converter.handleNotification('item/completed', {
item: { id: 'r1', type: 'Reasoning', summary_text: ['Plan'] }
});
const second = converter.handleNotification('item/completed', {
item: { id: 'r1', type: 'reasoning', summary_text: ['Plan'] }
});
expect(first).toEqual([{ type: 'agent_reasoning', text: 'Plan' }]);
expect(second).toEqual([]);
});
it('maps turn plan updates into update_plan events', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('turn/plan/updated', {
plan: [
{ step: 'Inspect Codex events', status: 'completed' },
{ content: 'Render plan state', status: 'in_progress' },
{ title: 'Verify web DOM', status: 'pending' }
]
});
expect(events).toEqual([{
type: 'plan_update',
plan: [
{ step: 'Inspect Codex events', status: 'completed' },
{ step: 'Render plan state', status: 'in_progress' },
{ step: 'Verify web DOM', status: 'pending' }
]
}]);
});
it('unwraps wrapped codex plan updates', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('codex/event/plan_update', {
msg: {
type: 'plan_update',
update: {
items: [
{ text: 'Plan from wrapped event', status: 'completed' }
]
}
}
});
expect(events).toEqual([{
type: 'plan_update',
plan: [
{ step: 'Plan from wrapped event', status: 'completed' }
]
}]);
});
it('maps diff updates', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('turn/diff/updated', { diff: 'diff --git a b' });
expect(events).toEqual([{ type: 'turn_diff', unified_diff: 'diff --git a b' }]);
});
it('preserves scope on diff and token usage updates', () => {
const converter = new AppServerEventConverter();
const diffEvents = converter.handleNotification('turn/diff/updated', {
threadId: 'child-thread',
turnId: 'child-turn',
diff: 'diff --git a b'
});
expect(diffEvents).toEqual([{
type: 'turn_diff',
thread_id: 'child-thread',
turn_id: 'child-turn',
unified_diff: 'diff --git a b'
}]);
const tokenEvents = converter.handleNotification('thread/tokenUsage/updated', {
tokenUsage: {
thread_id: 'child-thread',
turn_id: 'child-turn',
last_token_usage: {
input_tokens: 10,
output_tokens: 2
}
}
});
expect(tokenEvents).toEqual([{
type: 'token_count',
thread_id: 'child-thread',
turn_id: 'child-turn',
info: {
thread_id: 'child-thread',
turn_id: 'child-turn',
last_token_usage: {
input_tokens: 10,
output_tokens: 2
}
}
}]);
});
it('maps compact notifications with scope', () => {
const converter = new AppServerEventConverter();
const direct = converter.handleNotification('thread/compacted', {
thread: { id: 'thread-1' }
});
expect(direct).toEqual([
{ type: 'thread_compacted', thread_id: 'thread-1' },
{ type: 'context_compacted', thread_id: 'thread-1' }
]);
const wrapped = converter.handleNotification('codex/event/context_compacted', {
msg: { type: 'context_compacted', thread_id: 'thread-2', turn_id: 'turn-2' }
});
expect(wrapped).toEqual([
{ type: 'thread_compacted', thread_id: 'thread-2', turn_id: 'turn-2' },
{ type: 'context_compacted', thread_id: 'thread-2', turn_id: 'turn-2' }
]);
});
it('unwraps codex/event task lifecycle', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('codex/event/task_started', {
msg: { type: 'task_started', turn_id: 'turn-1' }
});
expect(started).toEqual([{ type: 'task_started', turn_id: 'turn-1' }]);
const completed = converter.handleNotification('codex/event/task_complete', {
msg: { type: 'task_complete', turn_id: 'turn-1' }
});
expect(completed).toEqual([{ type: 'task_complete', turn_id: 'turn-1' }]);
});
it('preserves nested scope on wrapped terminal lifecycle events', () => {
const converter = new AppServerEventConverter();
const completed = converter.handleNotification('codex/event/task_complete', {
msg: {
type: 'task_complete',
thread: { id: 'child-thread' },
turn: { id: 'child-turn' }
}
});
expect(completed).toEqual([{
type: 'task_complete',
thread_id: 'child-thread',
turn_id: 'child-turn'
}]);
});
it('ignores wrapped terminal lifecycle events without turn_id', () => {
const converter = new AppServerEventConverter();
const completed = converter.handleNotification('codex/event/task_complete', {
msg: { type: 'task_complete' }
});
expect(completed).toEqual([]);
});
it('unwraps codex/event agent deltas and item completion', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('codex/event/agent_message_delta', {
msg: { type: 'agent_message_delta', item_id: 'msg-1', delta: 'Hello' }
});
converter.handleNotification('codex/event/agent_message_content_delta', {
msg: { type: 'agent_message_content_delta', item_id: 'msg-1', delta: ' world' }
});
const completed = converter.handleNotification('codex/event/item_completed', {
msg: {
type: 'item_completed',
item_id: 'msg-1',
item: { id: 'msg-1', type: 'AgentMessage' }
}
});
expect(completed).toEqual([{ type: 'agent_message', message: 'Hello world' }]);
});
it('preserves nested scope on wrapped item lifecycle events', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('codex/event/item_started', {
msg: {
type: 'item_started',
thread: { id: 'child-thread' },
turn: { id: 'child-turn' },
item: { id: 'cmd-1', type: 'commandExecution', command: 'pwd' }
}
});
expect(started).toEqual([{
type: 'exec_command_begin',
thread_id: 'child-thread',
turn_id: 'child-turn',
call_id: 'cmd-1',
command: 'pwd'
}]);
const completed = converter.handleNotification('codex/event/item_completed', {
msg: {
type: 'item_completed',
item_id: 'msg-1',
item: {
id: 'msg-1',
type: 'AgentMessage',
message: 'child output',
thread: { id: 'child-thread' },
turn: { id: 'child-turn' }
}
}
});
expect(completed).toEqual([{
type: 'agent_message',
thread_id: 'child-thread',
turn_id: 'child-turn',
message: 'child output'
}]);
});
it('unwraps codex/event reasoning completion from summary text', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('codex/event/reasoning_content_delta', {
msg: { type: 'reasoning_content_delta', item_id: 'r1', delta: 'Plan' }
});
const completed = converter.handleNotification('codex/event/item_completed', {
msg: {
type: 'item_completed',
item_id: 'r1',
item: { id: 'r1', type: 'Reasoning', summary_text: ['Plan done'] }
}
});
expect(completed).toEqual([{ type: 'agent_reasoning', text: 'Plan done' }]);
});
it('prefers canonical reasoning stream over wrapped agent_reasoning events', () => {
const converter = new AppServerEventConverter();
const section = converter.handleNotification('codex/event/agent_reasoning_section_break', {
msg: { type: 'agent_reasoning_section_break', item_id: 'r1' }
});
const delta = converter.handleNotification('codex/event/agent_reasoning_delta', {
msg: { type: 'agent_reasoning_delta', item_id: 'r1', delta: 'step' }
});
const reasoning = converter.handleNotification('codex/event/agent_reasoning', {
msg: { type: 'agent_reasoning', item_id: 'r1', text: 'Plan' }
});
expect(section).toEqual([{ type: 'agent_reasoning_section_break' }]);
expect(delta).toEqual([]);
expect(reasoning).toEqual([]);
});
it('deduplicates section break when wrapped and direct summary part events share the same index', () => {
const converter = new AppServerEventConverter();
const wrapped = converter.handleNotification('codex/event/agent_reasoning_section_break', {
msg: { type: 'agent_reasoning_section_break', item_id: 'r1', summary_index: 0 }
});
const direct = converter.handleNotification('item/reasoning/summaryPartAdded', {
itemId: 'r1',
summaryIndex: 0
});
expect(wrapped).toEqual([{ type: 'agent_reasoning_section_break' }]);
expect(direct).toEqual([]);
});
it('ignores wrapped final agent message and relies on item completion', () => {
const converter = new AppServerEventConverter();
const wrapped = converter.handleNotification('codex/event/agent_message', {
msg: { type: 'agent_message', item_id: 'msg-1', message: 'Hello' }
});
expect(wrapped).toEqual([]);
});
it('ignores wrapped retryable errors', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('codex/event/error', {
msg: { type: 'error', message: 'temporary', will_retry: true }
});
expect(events).toEqual([]);
});
it('maps wrapped non-retryable errors to task_failed', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('codex/event/error', {
msg: { type: 'error', message: 'fatal' }
});
expect(events).toEqual([{ type: 'task_failed', error: 'fatal' }]);
});
it('preserves typed non-retryable cyber-policy errors', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('error', {
threadId: 'thread-1',
turnId: 'turn-1',
error: {
message: 'This content was flagged for possible cybersecurity risk.',
codexErrorInfo: 'cyberPolicy'
},
willRetry: false
});
expect(events).toEqual([{
type: 'task_failed',
thread_id: 'thread-1',
turn_id: 'turn-1',
terminal_source: 'error',
retryable: false,
codex_error_info: 'cyberPolicy',
error: 'This content was flagged for possible cybersecurity risk.'
}]);
});
it('preserves cyber-policy metadata from wrapped and completed-turn errors', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('codex/event/error', {
msg: {
type: 'error',
thread_id: 'thread-1',
turn_id: 'turn-1',
message: 'wrapped policy failure',
codex_error_info: 'cyber_policy',
will_retry: false
}
})).toEqual([{
type: 'task_failed',
thread_id: 'thread-1',
turn_id: 'turn-1',
retryable: false,
codex_error_info: 'cyber_policy',
error: 'wrapped policy failure'
}]);
expect(converter.handleNotification('turn/completed', {
threadId: 'thread-1',
turn: {
id: 'turn-1',
status: 'failed',
error: {
message: 'completed policy failure',
codexErrorInfo: 'CyberPolicy'
}
}
})).toEqual([{
type: 'task_failed',
thread_id: 'thread-1',
turn_id: 'turn-1',
terminal_source: 'turn_completed',
codex_error_info: 'CyberPolicy',
error: 'completed policy failure'
}]);
});
it('maps Codex model safety notifications', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('model/safetyBuffering/updated', {
threadId: 'thread-1',
turnId: 'turn-1',
model: 'gpt-5.4',
useCases: ['cyber'],
reasons: ['review'],
showBufferingUi: true,
fasterModel: 'gpt-5.4-mini'
})).toEqual([{
type: 'model_safety_buffering',
thread_id: 'thread-1',
turn_id: 'turn-1',
model: 'gpt-5.4',
use_cases: ['cyber'],
reasons: ['review'],
show_buffering_ui: true,
faster_model: 'gpt-5.4-mini'
}]);
expect(converter.handleNotification('model/rerouted', {
threadId: 'thread-1',
turnId: 'turn-1',
fromModel: 'gpt-5.4',
toModel: 'gpt-5.4-codex',
reason: 'highRiskCyberActivity'
})).toEqual([{
type: 'model_rerouted',
thread_id: 'thread-1',
turn_id: 'turn-1',
from_model: 'gpt-5.4',
to_model: 'gpt-5.4-codex',
reason: 'highRiskCyberActivity'
}]);
expect(converter.handleNotification('model/verification', {
threadId: 'thread-1',
turnId: 'turn-1',
verifications: ['trustedAccessForCyber']
})).toEqual([{
type: 'model_verification',
thread_id: 'thread-1',
turn_id: 'turn-1',
verifications: ['trustedAccessForCyber']
}]);
});
it('maps thread/compacted notifications', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/compacted', {
threadId: 'thread-1',
turnId: 'turn-compact'
});
expect(events).toEqual([
{
type: 'thread_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact'
},
{
type: 'context_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact'
}
]);
});
it('maps completed contextCompaction items and preserves the turn boundary', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('item/completed', {
threadId: 'thread-1',
turnId: 'turn-compact',
item: { id: 'compact-item-1', type: 'contextCompaction' }
});
expect(events).toEqual([
{
type: 'thread_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact',
await_turn_completion: true
},
{
type: 'context_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact'
}
]);
});
it('ignores compacted notifications without thread ids', () => {
const converter = new AppServerEventConverter();
expect(converter.handleNotification('thread/compacted', { turnId: 'turn-compact' })).toEqual([]);
expect(converter.handleNotification('codex/event/context_compacted', {
msg: { type: 'context_compacted', turn_id: 'turn-compact' }
})).toEqual([]);
});
it('unwraps context_compacted events', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('codex/event/context_compacted', {
msg: { type: 'context_compacted', thread_id: 'thread-1', turn_id: 'turn-compact' }
});
expect(events).toEqual([
{
type: 'thread_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact'
},
{
type: 'context_compacted',
thread_id: 'thread-1',
turn_id: 'turn-compact'
}
]);
});
it('converts completed image generation items without including large result payloads', () => {
const converter = new AppServerEventConverter();
const largeImageResult = 'a'.repeat(4096);
const events = converter.handleNotification('item/completed', {
item: {
id: 'image-1',
type: 'imageGeneration',
result: largeImageResult,
savedPath: '/tmp/image.png',
mimeType: 'image/png'
}
});
expect(events).toEqual([{
type: 'generated_image',
image_id: 'image-1',
saved_path: '/tmp/image.png',
file_name: 'image.png',
mime_type: 'image/png'
}]);
expect(JSON.stringify(events)).not.toContain(largeImageResult);
});
it('truncates large unhandled notification payloads before logging', () => {
const converter = new AppServerEventConverter();
const debug = vi.spyOn(logger, 'debug').mockImplementation(() => undefined);
const largeResult = 'a'.repeat(4096);
const events = converter.handleNotification('item/completed', {
item: {
id: 'unknown-1',
type: 'unknownLargePayload',
result: largeResult,
savedPath: '/tmp/image.png'
}
});
expect(events).toEqual([]);
expect(debug).toHaveBeenCalledTimes(1);
const logged = debug.mock.calls[0]?.[1] as { params?: { item?: { result?: string; savedPath?: string } } };
expect(logged.params?.item?.result).not.toBe(largeResult);
expect(logged.params?.item?.result).toContain('[truncated 3584 chars for logs]');
expect(logged.params?.item?.savedPath).toBe('/tmp/image.png');
debug.mockRestore();
});
});