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
+695 -3
View File
@@ -17,7 +17,18 @@ const harness = vi.hoisted(() => ({
failResumeThreadIds: [] as string[],
nextThreadSystemErrorMessage: null as string | null,
failNextCompact: false,
deferThreadStatusNotifications: false
deferThreadStatusNotifications: false,
emitChildThreadEvents: false,
emitChildUsageEvents: false,
emitChildReasoningBurst: false,
emitParentUsageEvents: false,
emitChildNestedAgentTool: false,
emitParentTitleChange: false,
emitParentSpawnFailureWithoutAgentId: false,
emitParentSpawnStartWithoutEnd: false,
emitParentSendInputFailure: false,
emitParentResumeSuccess: false,
bridgeOptions: [] as unknown[]
}));
vi.mock('./codexAppServerClient', () => {
@@ -97,6 +108,40 @@ vi.mock('./codexAppServerClient', () => {
}
if (params?.threadId === 'thread-1') {
if (harness.emitParentTitleChange) {
const titleStart = {
item: {
id: 'title-parent',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
arguments: { title: 'Parent Title' }
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/started', params: titleStart });
this.notificationHandler?.('item/started', titleStart);
const titleEnd = {
item: {
id: 'title-parent',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
result: {
content: [
{ type: 'text', text: 'Successfully changed chat title to: "Parent Title"' }
]
}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/completed', params: titleEnd });
this.notificationHandler?.('item/completed', titleEnd);
}
const commandStart = {
item: {
id: 'cmd-1',
@@ -120,6 +165,330 @@ vi.mock('./codexAppServerClient', () => {
};
harness.notifications.push({ method: 'item/completed', params: commandEnd });
this.notificationHandler?.('item/completed', commandEnd);
if (harness.emitParentUsageEvents) {
const parentUsage = {
tokenUsage: {
thread_id: threadId,
turn_id: turnId,
last_token_usage: {
input_tokens: 100,
output_tokens: 10
},
model_context_window: 200_000
}
};
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: parentUsage });
this.notificationHandler?.('thread/tokenUsage/updated', parentUsage);
const parentCompact = { thread: { id: threadId } };
harness.notifications.push({ method: 'thread/compacted', params: parentCompact });
this.notificationHandler?.('thread/compacted', parentCompact);
}
if (harness.emitParentSpawnFailureWithoutAgentId || harness.emitParentSpawnStartWithoutEnd) {
const spawnStart = {
item: {
id: 'failed-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
prompt: 'do side work',
reasoningEffort: 'medium',
senderThreadId: threadId,
receiverThreadIds: []
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/started', params: spawnStart });
this.notificationHandler?.('item/started', spawnStart);
if (harness.emitParentSpawnFailureWithoutAgentId) {
const spawnCompleted = {
item: {
id: 'failed-spawn',
type: 'collabAgentToolCall',
tool: 'spawnAgent',
status: 'failed',
error: 'invalid spawn arguments',
senderThreadId: threadId,
receiverThreadIds: [],
agentsStates: {}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/completed', params: spawnCompleted });
this.notificationHandler?.('item/completed', spawnCompleted);
}
}
}
if (harness.emitChildThreadEvents) {
const childThreadId = 'child-thread';
const childTurnId = 'child-turn';
const childMessage = 'child output should stay hidden';
if (harness.emitChildReasoningBurst) {
for (let i = 0; i < 20; i += 1) {
const reasoningDelta = {
msg: {
type: 'reasoning_content_delta',
item_id: 'child-reasoning',
delta: `step-${i} `,
thread_id: childThreadId,
turn_id: childTurnId
}
};
harness.notifications.push({ method: 'codex/event/reasoning_content_delta', params: reasoningDelta });
this.notificationHandler?.('codex/event/reasoning_content_delta', reasoningDelta);
}
}
const childMessageCompleted = {
item: {
id: 'child-msg-1',
type: 'agentMessage',
content: [{ type: 'text', text: childMessage }]
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/completed', params: childMessageCompleted });
this.notificationHandler?.('item/completed', childMessageCompleted);
if (harness.emitChildUsageEvents) {
const childUsage = {
tokenUsage: {
thread_id: childThreadId,
turn_id: childTurnId,
last_token_usage: {
input_tokens: 30,
output_tokens: 3
},
model_context_window: 200_000
}
};
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: childUsage });
this.notificationHandler?.('thread/tokenUsage/updated', childUsage);
const childCompact = {
msg: {
type: 'context_compacted',
thread_id: childThreadId,
turn_id: childTurnId
}
};
harness.notifications.push({ method: 'codex/event/context_compacted', params: childCompact });
this.notificationHandler?.('codex/event/context_compacted', childCompact);
const ambiguousUsage = {
tokenUsage: {
last_token_usage: {
input_tokens: 999,
output_tokens: 1
}
}
};
harness.notifications.push({ method: 'thread/tokenUsage/updated', params: ambiguousUsage });
this.notificationHandler?.('thread/tokenUsage/updated', ambiguousUsage);
}
const childCommandStart = {
item: {
id: 'child-cmd-1',
type: 'commandExecution',
command: 'echo child'
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/started', params: childCommandStart });
this.notificationHandler?.('item/started', childCommandStart);
this.notificationHandler?.('item/commandExecution/outputDelta', {
itemId: 'child-cmd-1',
delta: 'child stdout\n',
threadId: childThreadId,
turnId: childTurnId
});
const childCommandEnd = {
item: {
id: 'child-cmd-1',
type: 'commandExecution',
exitCode: 0
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/completed', params: childCommandEnd });
this.notificationHandler?.('item/completed', childCommandEnd);
const childTitleStart = {
item: {
id: 'title-child',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
arguments: { title: 'Child Title' }
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/started', params: childTitleStart });
this.notificationHandler?.('item/started', childTitleStart);
const childTitleEnd = {
item: {
id: 'title-child',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
result: {
content: [
{ type: 'text', text: 'Successfully changed chat title to: "Child Title"' }
]
}
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/completed', params: childTitleEnd });
this.notificationHandler?.('item/completed', childTitleEnd);
if (harness.emitChildNestedAgentTool) {
const nestedSpawnStart = {
item: {
id: 'nested-spawn',
type: 'collabAgentToolCall',
tool: 'spawn',
senderThreadId: childThreadId,
receiverThreadIds: ['grandchild-thread'],
prompt: 'do nested work'
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/started', params: nestedSpawnStart });
this.notificationHandler?.('item/started', nestedSpawnStart);
const nestedSpawnCompleted = {
item: {
id: 'nested-spawn',
type: 'collabAgentToolCall',
tool: 'spawn',
status: 'completed',
senderThreadId: childThreadId,
receiverThreadIds: ['grandchild-thread'],
agentsStates: {}
},
threadId: childThreadId,
turnId: childTurnId
};
harness.notifications.push({ method: 'item/completed', params: nestedSpawnCompleted });
this.notificationHandler?.('item/completed', nestedSpawnCompleted);
}
const waitStarted = {
item: {
id: 'wait-child',
type: 'collabAgentToolCall',
tool: 'wait',
senderThreadId: threadId,
receiverThreadIds: [childThreadId],
agentsStates: {}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/started', params: waitStarted });
this.notificationHandler?.('item/started', waitStarted);
const waitCompleted = {
item: {
id: 'wait-child',
type: 'collabAgentToolCall',
tool: 'wait',
status: 'completed',
senderThreadId: threadId,
receiverThreadIds: [childThreadId],
agentsStates: {
[childThreadId]: {
status: 'completed',
message: childMessage
}
}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/completed', params: waitCompleted });
this.notificationHandler?.('item/completed', waitCompleted);
if (harness.emitParentSendInputFailure) {
const sendInputStarted = {
item: {
id: 'send-child',
type: 'collabAgentToolCall',
tool: 'sendInput',
senderThreadId: threadId,
receiverThreadIds: [childThreadId],
message: 'follow up'
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/started', params: sendInputStarted });
this.notificationHandler?.('item/started', sendInputStarted);
const sendInputCompleted = {
item: {
id: 'send-child',
type: 'collabAgentToolCall',
tool: 'sendInput',
status: 'failed',
error: 'send failed',
senderThreadId: threadId,
receiverThreadIds: [childThreadId],
agentsStates: {}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/completed', params: sendInputCompleted });
this.notificationHandler?.('item/completed', sendInputCompleted);
}
if (harness.emitParentResumeSuccess) {
const resumeStarted = {
item: {
id: 'resume-child',
type: 'collabAgentToolCall',
tool: 'resumeAgent',
senderThreadId: threadId,
receiverThreadIds: [childThreadId]
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/started', params: resumeStarted });
this.notificationHandler?.('item/started', resumeStarted);
const resumeCompleted = {
item: {
id: 'resume-child',
type: 'collabAgentToolCall',
tool: 'resumeAgent',
status: 'completed',
senderThreadId: threadId,
receiverThreadIds: [childThreadId],
agentsStates: {}
},
threadId,
turnId
};
harness.notifications.push({ method: 'item/completed', params: resumeCompleted });
this.notificationHandler?.('item/completed', resumeCompleted);
}
}
const completed = { status: 'Completed', turn: { id: turnId } };
@@ -144,12 +513,15 @@ vi.mock('./codexAppServerClient', () => {
});
vi.mock('./utils/buildHapiMcpBridge', () => ({
buildHapiMcpBridge: async () => ({
buildHapiMcpBridge: async (_client: unknown, options?: unknown) => {
harness.bridgeOptions.push(options);
return {
server: {
stop: () => {}
},
mcpServers: {}
})
};
}
}));
import { codexRemoteLauncher } from './codexRemoteLauncher';
@@ -179,6 +551,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
const codexMessages: unknown[] = [];
const summaryMessages: unknown[] = [];
const thinkingChanges: boolean[] = [];
const foundSessionIds: string[] = [];
const resetThreadCalls: string[] = [];
@@ -202,6 +575,9 @@ function createSessionStub(messages = ['hello from launcher test']) {
codexMessages.push(message);
},
sendUserMessage(_text: string) {},
sendClaudeSessionMessage(message: unknown) {
summaryMessages.push(message);
},
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
sessionEvents.push(event);
}
@@ -252,6 +628,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
session,
sessionEvents,
codexMessages,
summaryMessages,
thinkingChanges,
foundSessionIds,
resetThreadCalls,
@@ -278,6 +655,17 @@ describe('codexRemoteLauncher', () => {
harness.nextThreadSystemErrorMessage = null;
harness.failNextCompact = false;
harness.deferThreadStatusNotifications = false;
harness.emitChildThreadEvents = false;
harness.emitChildUsageEvents = false;
harness.emitChildReasoningBurst = false;
harness.emitParentUsageEvents = false;
harness.emitChildNestedAgentTool = false;
harness.emitParentTitleChange = false;
harness.emitParentSpawnFailureWithoutAgentId = false;
harness.emitParentSpawnStartWithoutEnd = false;
harness.emitParentSendInputFailure = false;
harness.emitParentResumeSuccess = false;
harness.bridgeOptions = [];
});
it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => {
@@ -506,6 +894,310 @@ describe('codexRemoteLauncher', () => {
}));
});
it('routes child thread messages into agent-run trace while keeping them out of the parent timeline', async () => {
harness.emitChildThreadEvents = true;
const { session, codexMessages, summaryMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'message',
message: 'child output should stay hidden'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'tool-call',
callId: 'child-cmd-1'
}));
expect(summaryMessages).not.toContainEqual(expect.objectContaining({
type: 'summary',
summary: 'Child Title'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'tool-call',
name: 'wait_agent',
callId: 'wait-child'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-trace',
agentId: 'child-thread',
message: expect.objectContaining({
type: 'message',
message: 'child output should stay hidden'
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-trace',
agentId: 'child-thread',
message: expect.objectContaining({
type: 'tool-call',
callId: 'child-cmd-1'
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
activity: 'Running command: echo child',
activityKind: 'running-command'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
summary: 'Child Title'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'completed',
result: 'child output should stay hidden',
activity: 'Completed: child output should stay hidden',
activityKind: 'completed'
}));
});
it('surfaces send_input failures on the target child agent card', async () => {
harness.emitChildThreadEvents = true;
harness.emitParentSendInputFailure = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'running',
statusText: 'Sending input',
activity: 'Sending input',
activityKind: 'send_input'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'failed',
statusText: 'Send input failed',
activity: 'Send input failed: send failed',
activityKind: 'failed',
error: expect.objectContaining({
error: 'send failed'
})
}));
});
it('updates the target child agent card when resume_agent completes', async () => {
harness.emitChildThreadEvents = true;
harness.emitParentResumeSuccess = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'running',
statusText: 'Resuming agent',
activity: 'Resuming agent',
activityKind: 'resume_agent'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'running',
statusText: 'Resumed',
activity: 'Resumed',
activityKind: 'resume_agent',
result: expect.objectContaining({
status: 'completed',
targets: ['child-thread']
})
}));
});
it('throttles child agent reasoning activity updates instead of emitting one per delta', async () => {
harness.emitChildThreadEvents = true;
harness.emitChildReasoningBurst = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
const thinkingUpdates = codexMessages.filter((message): message is Record<string, unknown> => {
return typeof message === 'object'
&& message !== null
&& (message as Record<string, unknown>).type === 'agent-run-update'
&& (message as Record<string, unknown>).agentId === 'child-thread'
&& (message as Record<string, unknown>).activityKind === 'thinking';
});
expect(thinkingUpdates.length).toBeLessThan(20);
expect(thinkingUpdates.length).toBeLessThanOrEqual(1);
});
it('keeps child usage and compact events out of the parent context stream', async () => {
harness.emitChildThreadEvents = true;
harness.emitChildUsageEvents = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'token_count',
thread_id: 'child-thread'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'token_count',
info: expect.objectContaining({
last_token_usage: expect.objectContaining({
input_tokens: 999
})
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-trace',
agentId: 'child-thread',
message: expect.objectContaining({
type: 'context_compacted'
})
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'context_compacted',
thread_id: 'child-thread'
}));
});
it('marks parent usage and compact events with parent scope', async () => {
harness.emitParentUsageEvents = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'token_count',
thread_id: 'thread-1',
scope_role: 'parent',
scope: expect.objectContaining({
role: 'parent',
thread_id: 'thread-1'
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'context_compacted',
thread_id: 'thread-1',
scope_role: 'parent',
scope: expect.objectContaining({
role: 'parent',
thread_id: 'thread-1'
})
}));
});
it('marks child agents failed when they attempt to start nested agents', async () => {
harness.emitChildThreadEvents = true;
harness.emitChildNestedAgentTool = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-trace',
agentId: 'child-thread',
message: expect.objectContaining({
type: 'tool-call',
name: 'spawn_agent',
callId: 'nested-spawn'
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-trace',
agentId: 'child-thread',
message: expect.objectContaining({
type: 'tool-call-result',
callId: 'nested-spawn',
is_error: true,
output: 'Nested agent calls are disabled for child agents.'
})
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'child-thread',
status: 'failed',
activity: 'Failed: Nested agent calls are disabled for child agents.',
activityKind: 'failed'
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'grandchild-thread'
}));
});
it('marks spawn_agent cards failed when Codex returns no agent id', async () => {
harness.emitParentSpawnFailureWithoutAgentId = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-start',
cardId: 'failed-spawn',
status: 'starting'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'spawn-error:failed-spawn',
cardId: 'failed-spawn',
status: 'failed',
statusText: 'Failed to start',
activityKind: 'failed',
error: expect.objectContaining({
status: 'failed',
error: 'invalid spawn arguments'
})
}));
});
it('marks pending spawn_agent cards failed when the session ends before a result', async () => {
harness.emitParentSpawnStartWithoutEnd = true;
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-start',
cardId: 'failed-spawn',
status: 'starting'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'agent-run-update',
agentId: 'spawn-error:failed-spawn',
cardId: 'failed-spawn',
status: 'failed',
statusText: 'Failed to start',
activityKind: 'failed',
error: 'spawn_agent did not return an agent id before the Codex session ended'
}));
});
it('applies parent-thread hapi change_title after disabling MCP-side title writes', async () => {
harness.emitParentTitleChange = true;
const { session, codexMessages, summaryMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(harness.bridgeOptions).toEqual([{ emitTitleSummary: false }]);
expect(summaryMessages).toContainEqual(expect.objectContaining({
type: 'summary',
summary: 'Parent Title'
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call',
name: 'mcp__hapi__change_title',
callId: 'title-parent',
input: { title: 'Parent Title' }
}));
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call-result',
callId: 'title-parent',
is_error: false
}));
});
it('clears codex thread state without starting a turn', async () => {
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']);