Files
hapi/cli/src/codex/utils/appServerEventConverter.test.ts
T
CoColateandGitHub e76738aa5a fix(codex): improve web rendering for Codex events (#544)
* test(codex): add web event rendering harness

* fix(codex): surface plan updates in web

* fix(codex): render MCP tool calls in web

* fix(codex): improve terminal and context display

* fix(codex): format token usage events

* fix(codex): show status context in web

* fix(codex): preserve tool result errors
2026-04-29 17:22:45 +08:00

393 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest';
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 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', 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('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 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('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('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('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' }]);
});
});