mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-14 20:26:05 +00:00
Add Codex multi-agent timeline support (#588)
* checkpoint codex multiagent UI state * fix codex multiagent event scoping * fix: stabilize codex subagent timeline * chore: remove codex subagent nesting prompt * test(web): stabilize tool result rendering tests * fix: collapse codex agent trace rows by default * fix(web): keep chat scrolled to bottom * fix(web): backfill agent-run-heavy message loads * fix(codex): fail stuck subagent spawns * fix(codex): preserve wrapped child event scope * fix(codex): surface agent tool completions
This commit is contained in:
@@ -59,6 +59,29 @@ describe('AppServerEventConverter', () => {
|
||||
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();
|
||||
|
||||
@@ -169,6 +192,134 @@ describe('AppServerEventConverter', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(waitCompleted).toEqual([{
|
||||
type: 'codex_tool_call_end',
|
||||
call_id: 'call-wait',
|
||||
name: 'wait_agent',
|
||||
output: {
|
||||
status: {
|
||||
'agent-1': { completed: '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 reasoning deltas', () => {
|
||||
const converter = new AppServerEventConverter();
|
||||
|
||||
@@ -265,6 +416,66 @@ describe('AppServerEventConverter', () => {
|
||||
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();
|
||||
|
||||
@@ -279,6 +490,24 @@ describe('AppServerEventConverter', () => {
|
||||
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();
|
||||
|
||||
@@ -310,6 +539,46 @@ describe('AppServerEventConverter', () => {
|
||||
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();
|
||||
|
||||
@@ -397,11 +666,18 @@ describe('AppServerEventConverter', () => {
|
||||
turnId: 'turn-compact'
|
||||
});
|
||||
|
||||
expect(events).toEqual([{
|
||||
type: 'thread_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('ignores compacted notifications without thread ids', () => {
|
||||
@@ -419,11 +695,18 @@ describe('AppServerEventConverter', () => {
|
||||
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'
|
||||
}]);
|
||||
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'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user