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:
SmallSpider
2026-05-07 10:46:16 +08:00
committed by GitHub
parent 0006d04f9e
commit 841b7cc035
31 changed files with 5745 additions and 152 deletions
@@ -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'
}
]);
});
});