mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-04 00:01:41 +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:
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user