mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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
393 lines
15 KiB
TypeScript
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' }]);
|
|
});
|
|
});
|