mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +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']);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig';
|
||||
import {
|
||||
buildThreadStartParams,
|
||||
buildTurnStartParams,
|
||||
codexCollaborationSpawnAgentInstructions
|
||||
} from './appServerConfig';
|
||||
import { codexSystemPrompt } from './systemPrompt';
|
||||
|
||||
describe('appServerConfig', () => {
|
||||
const mcpServers = { hapi: { command: 'node', args: ['mcp'] } };
|
||||
const withCollaborationInstructions = (developerInstructions: string): string => {
|
||||
return `${developerInstructions}\n\n${codexCollaborationSpawnAgentInstructions}`;
|
||||
};
|
||||
|
||||
it('applies CLI overrides when permission mode is default', () => {
|
||||
const params = buildThreadStartParams({
|
||||
@@ -122,7 +129,7 @@ describe('appServerConfig', () => {
|
||||
settings: {
|
||||
model: 'o3',
|
||||
reasoning_effort: 'high',
|
||||
developer_instructions: codexSystemPrompt
|
||||
developer_instructions: withCollaborationInstructions(codexSystemPrompt)
|
||||
}
|
||||
});
|
||||
expect(params.model).toBeUndefined();
|
||||
@@ -146,7 +153,7 @@ describe('appServerConfig', () => {
|
||||
settings: {
|
||||
model: 'o3',
|
||||
reasoning_effort: 'high',
|
||||
developer_instructions: codexSystemPrompt
|
||||
developer_instructions: withCollaborationInstructions(codexSystemPrompt)
|
||||
}
|
||||
});
|
||||
expect(params.model).toBeUndefined();
|
||||
@@ -165,11 +172,25 @@ describe('appServerConfig', () => {
|
||||
mode: 'plan',
|
||||
settings: {
|
||||
model: 'o3',
|
||||
developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.`
|
||||
developer_instructions: withCollaborationInstructions(`${codexSystemPrompt}\n\nOnly respond in Chinese.`)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('injects spawn_agent argument rules into collaboration mode instructions', () => {
|
||||
const params = buildTurnStartParams({
|
||||
threadId: 'thread-1',
|
||||
message: 'hello',
|
||||
cwd: '/workspace/project',
|
||||
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' }
|
||||
});
|
||||
|
||||
const instructions = params.collaborationMode?.settings.developer_instructions;
|
||||
expect(instructions).toContain('If you call spawn_agent with fork_context: true');
|
||||
expect(instructions).toContain('do not set agent_type, model, or reasoning_effort');
|
||||
expect(instructions).toContain('omit fork_context or set fork_context: false');
|
||||
});
|
||||
|
||||
it('rejects collaboration mode payloads without a resolved model', () => {
|
||||
expect(() => buildTurnStartParams({
|
||||
threadId: 'thread-1',
|
||||
@@ -194,7 +215,7 @@ describe('appServerConfig', () => {
|
||||
mode: 'default',
|
||||
settings: {
|
||||
model: 'o3',
|
||||
developer_instructions: codexSystemPrompt
|
||||
developer_instructions: withCollaborationInstructions(codexSystemPrompt)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -214,7 +235,7 @@ describe('appServerConfig', () => {
|
||||
mode: 'default',
|
||||
settings: {
|
||||
model: 'o3',
|
||||
developer_instructions: codexSystemPrompt
|
||||
developer_instructions: withCollaborationInstructions(codexSystemPrompt)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -233,7 +254,7 @@ describe('appServerConfig', () => {
|
||||
mode: 'default',
|
||||
settings: {
|
||||
model: 'gpt-5',
|
||||
developer_instructions: codexSystemPrompt
|
||||
developer_instructions: withCollaborationInstructions(codexSystemPrompt)
|
||||
}
|
||||
});
|
||||
expect(params.model).toBeUndefined();
|
||||
|
||||
@@ -11,6 +11,12 @@ import type {
|
||||
} from '../appServerTypes';
|
||||
import { resolveCodexPermissionModeConfig } from './permissionModeConfig';
|
||||
|
||||
export const codexCollaborationSpawnAgentInstructions = [
|
||||
'Codex sub-agent spawning rules:',
|
||||
'- If you call spawn_agent with fork_context: true, do not set agent_type, model, or reasoning_effort; full-history forked agents inherit these values from the parent.',
|
||||
'- If you need a specific agent_type, model, or reasoning_effort, omit fork_context or set fork_context: false, and include only the necessary context in the message.'
|
||||
].join('\n');
|
||||
|
||||
function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy {
|
||||
return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy;
|
||||
}
|
||||
@@ -63,6 +69,10 @@ function resolveInstructions(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function appendCollaborationInstructions(developerInstructions: string): string {
|
||||
return `${developerInstructions}\n\n${codexCollaborationSpawnAgentInstructions}`;
|
||||
}
|
||||
|
||||
export function buildThreadStartParams(args: {
|
||||
cwd: string;
|
||||
mode: EnhancedMode;
|
||||
@@ -158,7 +168,7 @@ export function buildTurnStartParams(args: {
|
||||
settings: {
|
||||
model,
|
||||
...(args.mode?.modelReasoningEffort ? { reasoning_effort: args.mode.modelReasoningEffort } : {}),
|
||||
developer_instructions: developerInstructions
|
||||
developer_instructions: appendCollaborationInstructions(developerInstructions)
|
||||
}
|
||||
};
|
||||
} else if (model) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -167,6 +167,188 @@ function extractPlanUpdate(params: Record<string, unknown>): ConvertedEvent[] {
|
||||
return plan.length > 0 ? [{ type: 'plan_update', plan }] : [];
|
||||
}
|
||||
|
||||
function extractEventScope(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const thread = asRecord(params.thread);
|
||||
const turn = asRecord(params.turn);
|
||||
const tokenUsage = asRecord(params.tokenUsage ?? params.token_usage ?? params.info);
|
||||
const tokenUsageThread = asRecord(tokenUsage?.thread);
|
||||
const tokenUsageTurn = asRecord(tokenUsage?.turn);
|
||||
const item = asRecord(params.item);
|
||||
const itemThread = asRecord(item?.thread);
|
||||
const itemTurn = asRecord(item?.turn);
|
||||
const threadId = asString(
|
||||
params.threadId
|
||||
?? params.thread_id
|
||||
?? thread?.threadId
|
||||
?? thread?.thread_id
|
||||
?? thread?.id
|
||||
?? tokenUsage?.threadId
|
||||
?? tokenUsage?.thread_id
|
||||
?? tokenUsageThread?.threadId
|
||||
?? tokenUsageThread?.thread_id
|
||||
?? tokenUsageThread?.id
|
||||
?? item?.threadId
|
||||
?? item?.thread_id
|
||||
?? itemThread?.threadId
|
||||
?? itemThread?.thread_id
|
||||
?? itemThread?.id
|
||||
);
|
||||
const turnId = asString(
|
||||
params.turnId
|
||||
?? params.turn_id
|
||||
?? turn?.turnId
|
||||
?? turn?.turn_id
|
||||
?? turn?.id
|
||||
?? tokenUsage?.turnId
|
||||
?? tokenUsage?.turn_id
|
||||
?? tokenUsageTurn?.turnId
|
||||
?? tokenUsageTurn?.turn_id
|
||||
?? tokenUsageTurn?.id
|
||||
?? item?.turnId
|
||||
?? item?.turn_id
|
||||
?? itemTurn?.turnId
|
||||
?? itemTurn?.turn_id
|
||||
?? itemTurn?.id
|
||||
);
|
||||
|
||||
return {
|
||||
...(threadId ? { thread_id: threadId } : {}),
|
||||
...(turnId ? { turn_id: turnId } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function addEventScope(events: ConvertedEvent[], scope: Record<string, unknown>): ConvertedEvent[] {
|
||||
if (Object.keys(scope).length === 0) {
|
||||
return events;
|
||||
}
|
||||
|
||||
return events.map((event) => ({
|
||||
...scope,
|
||||
...event
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeCollabAgentToolName(value: unknown): string | null {
|
||||
const raw = asString(value);
|
||||
if (!raw) return null;
|
||||
|
||||
const normalized = raw.trim().toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (normalized === 'spawnagent' || normalized === 'spawn') return 'spawn_agent';
|
||||
if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
|
||||
if (normalized === 'resumeagent' || normalized === 'resume') return 'resume_agent';
|
||||
if (normalized === 'waitagent' || normalized === 'wait') return 'wait_agent';
|
||||
if (normalized === 'closeagent' || normalized === 'close') return 'close_agent';
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildCollabAgentInput(item: Record<string, unknown>, toolName: string): Record<string, unknown> {
|
||||
const targets = extractStringArray(item.receiverThreadIds ?? item.receiver_thread_ids ?? item.targets);
|
||||
const input: Record<string, unknown> = {};
|
||||
|
||||
const prompt = asString(item.prompt ?? item.message);
|
||||
if (prompt) {
|
||||
input.message = prompt;
|
||||
}
|
||||
|
||||
const agentType = asString(item.agentType ?? item.agent_type);
|
||||
if (agentType) {
|
||||
input.agent_type = agentType;
|
||||
}
|
||||
|
||||
const forkContext = asBoolean(item.forkContext ?? item.fork_context);
|
||||
if (forkContext !== null) {
|
||||
input.fork_context = forkContext;
|
||||
}
|
||||
|
||||
const model = asString(item.model);
|
||||
if (model) {
|
||||
input.model = model;
|
||||
}
|
||||
|
||||
const reasoningEffort = asString(item.reasoningEffort ?? item.reasoning_effort);
|
||||
if (reasoningEffort) {
|
||||
input.reasoning_effort = reasoningEffort;
|
||||
}
|
||||
|
||||
const senderThreadId = asString(item.senderThreadId ?? item.sender_thread_id);
|
||||
if (senderThreadId) {
|
||||
input.sender_thread_id = senderThreadId;
|
||||
}
|
||||
|
||||
if (targets.length > 0) {
|
||||
input.targets = targets;
|
||||
if (toolName === 'close_agent' || toolName === 'send_input' || toolName === 'resume_agent') {
|
||||
input.target = targets[0];
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function statusObjectFromAgentState(value: unknown): unknown {
|
||||
const record = asRecord(value);
|
||||
if (!record) return value;
|
||||
|
||||
const message = asString(record.message);
|
||||
const status = asString(record.status ?? record.state);
|
||||
if (status === 'completed' && message) return { completed: message };
|
||||
if ((status === 'failed' || status === 'error') && message) return { failed: message };
|
||||
if ((status === 'canceled' || status === 'cancelled') && message) return { canceled: message };
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildCollabAgentOutput(item: Record<string, unknown>, toolName: string): Record<string, unknown> {
|
||||
const targets = extractStringArray(item.receiverThreadIds ?? item.receiver_thread_ids ?? item.targets);
|
||||
const agentsStates = asRecord(item.agentsStates ?? item.agents_states) ?? {};
|
||||
const status = asString(item.status);
|
||||
const error = asString(item.error ?? item.message);
|
||||
const errorFields = error ? { error, message: error } : {};
|
||||
|
||||
if (toolName === 'spawn_agent') {
|
||||
const agentId = targets[0] ?? null;
|
||||
return {
|
||||
...(agentId ? { agent_id: agentId, agentId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...errorFields,
|
||||
agentsStates
|
||||
};
|
||||
}
|
||||
|
||||
if (toolName === 'wait_agent') {
|
||||
const normalizedStatus: Record<string, unknown> = {};
|
||||
for (const [agentId, agentStatus] of Object.entries(agentsStates)) {
|
||||
normalizedStatus[agentId] = statusObjectFromAgentState(agentStatus);
|
||||
}
|
||||
return {
|
||||
status: normalizedStatus,
|
||||
...errorFields,
|
||||
timed_out: status === 'timedOut' || status === 'timed_out'
|
||||
};
|
||||
}
|
||||
|
||||
if (toolName === 'close_agent') {
|
||||
const firstStatus = targets[0] ? agentsStates[targets[0]] : Object.values(agentsStates)[0];
|
||||
return {
|
||||
previous_status: statusObjectFromAgentState(firstStatus),
|
||||
...errorFields,
|
||||
...(targets[0] ? { agent_id: targets[0] } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...(targets.length > 0 ? { targets } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...errorFields,
|
||||
agentsStates
|
||||
};
|
||||
}
|
||||
|
||||
export class AppServerEventConverter {
|
||||
private readonly agentMessageBuffers = new Map<string, string>();
|
||||
private readonly reasoningBuffers = new Map<string, string>();
|
||||
@@ -191,14 +373,19 @@ export class AppServerEventConverter {
|
||||
return [];
|
||||
}
|
||||
|
||||
const msgScope = extractEventScope(msg);
|
||||
|
||||
if (msgType === 'item_started' || msgType === 'item_completed') {
|
||||
const itemMethod = msgType === 'item_started' ? 'item/started' : 'item/completed';
|
||||
const item = asRecord(msg.item) ?? {};
|
||||
const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id);
|
||||
const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id);
|
||||
const params: Record<string, unknown> = {
|
||||
...msgScope,
|
||||
item,
|
||||
itemId: asString(msg.item_id ?? msg.itemId ?? item.id),
|
||||
threadId: asString(msg.thread_id ?? msg.threadId),
|
||||
turnId: asString(msg.turn_id ?? msg.turnId)
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(turnId ? { turnId } : {})
|
||||
};
|
||||
return this.handleNotification(itemMethod, params);
|
||||
}
|
||||
@@ -209,16 +396,20 @@ export class AppServerEventConverter {
|
||||
msgType === 'turn_aborted' ||
|
||||
msgType === 'task_failed'
|
||||
) {
|
||||
const turnId = asString(msg.turn_id ?? msg.turnId);
|
||||
const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id);
|
||||
if ((msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') && !turnId) {
|
||||
logger.debug('[AppServerEventConverter] Ignoring wrapped terminal event without turn_id', { msgType });
|
||||
return [];
|
||||
}
|
||||
|
||||
const event: ConvertedEvent = { type: msgType };
|
||||
const event: ConvertedEvent = { ...msgScope, type: msgType };
|
||||
if (turnId) {
|
||||
event.turn_id = turnId;
|
||||
}
|
||||
const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id);
|
||||
if (threadId) {
|
||||
event.thread_id = threadId;
|
||||
}
|
||||
if (msgType === 'task_failed') {
|
||||
const error = asString(msg.error ?? msg.message ?? asRecord(msg.error)?.message);
|
||||
if (error) {
|
||||
@@ -232,14 +423,14 @@ export class AppServerEventConverter {
|
||||
const itemId = asString(msg.item_id ?? msg.itemId ?? msg.id) ?? 'agent-message';
|
||||
const delta = asString(msg.delta ?? msg.text ?? msg.message);
|
||||
if (!delta) return [];
|
||||
return this.handleNotification('item/agentMessage/delta', { itemId, delta });
|
||||
return this.handleNotification('item/agentMessage/delta', { itemId, delta, ...msgScope });
|
||||
}
|
||||
|
||||
if (msgType === 'reasoning_content_delta') {
|
||||
const itemId = asString(msg.item_id ?? msg.itemId ?? msg.id) ?? 'reasoning';
|
||||
const delta = asString(msg.delta ?? msg.text ?? msg.message);
|
||||
if (!delta) return [];
|
||||
return this.handleNotification('item/reasoning/summaryTextDelta', { itemId, delta });
|
||||
return this.handleNotification('item/reasoning/summaryTextDelta', { itemId, delta, ...msgScope });
|
||||
}
|
||||
|
||||
if (msgType === 'agent_reasoning_section_break') {
|
||||
@@ -247,6 +438,7 @@ export class AppServerEventConverter {
|
||||
const summaryIndex = asNumber(msg.summary_index ?? msg.summaryIndex);
|
||||
return this.handleNotification('item/reasoning/summaryPartAdded', {
|
||||
itemId,
|
||||
...msgScope,
|
||||
...(summaryIndex !== null ? { summaryIndex } : {})
|
||||
});
|
||||
}
|
||||
@@ -259,7 +451,7 @@ export class AppServerEventConverter {
|
||||
const itemId = asString(msg.call_id ?? msg.callId ?? msg.item_id ?? msg.itemId ?? msg.id);
|
||||
const delta = asString(msg.delta ?? msg.output ?? msg.stdout ?? msg.text);
|
||||
if (!itemId || !delta) return [];
|
||||
return this.handleNotification('item/commandExecution/outputDelta', { itemId, delta });
|
||||
return this.handleNotification('item/commandExecution/outputDelta', { itemId, delta, ...msgScope });
|
||||
}
|
||||
|
||||
if (msgType === 'error') {
|
||||
@@ -269,24 +461,27 @@ export class AppServerEventConverter {
|
||||
return [];
|
||||
}
|
||||
const error = asString(msg.message ?? msg.reason ?? errorRecord?.message);
|
||||
return error ? [{ type: 'task_failed', error }] : [];
|
||||
return error ? addEventScope([{ type: 'task_failed', error }], msgScope) : [];
|
||||
}
|
||||
|
||||
if (msgType === 'plan_update') {
|
||||
return extractPlanUpdate(msg);
|
||||
return addEventScope(extractPlanUpdate(msg), msgScope);
|
||||
}
|
||||
|
||||
if (msgType === 'context_compacted') {
|
||||
const threadId = asString(msg.thread_id ?? msg.threadId);
|
||||
const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id);
|
||||
if (!threadId) {
|
||||
return [];
|
||||
}
|
||||
const turnId = asString(msg.turn_id ?? msg.turnId);
|
||||
return [{
|
||||
type: 'thread_compacted',
|
||||
thread_id: threadId,
|
||||
...(turnId ? { turn_id: turnId } : {})
|
||||
}];
|
||||
const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id);
|
||||
return [
|
||||
{
|
||||
type: 'thread_compacted',
|
||||
thread_id: threadId,
|
||||
...(turnId ? { turn_id: turnId } : {})
|
||||
},
|
||||
...addEventScope([{ type: 'context_compacted' }], msgScope)
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -301,19 +496,24 @@ export class AppServerEventConverter {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [msg as ConvertedEvent];
|
||||
return addEventScope([msg as ConvertedEvent], msgScope);
|
||||
}
|
||||
|
||||
handleNotification(method: string, params: unknown): ConvertedEvent[] {
|
||||
const events: ConvertedEvent[] = [];
|
||||
const paramsRecord = asRecord(params) ?? {};
|
||||
const eventScope = extractEventScope(paramsRecord);
|
||||
const scoped = (event: ConvertedEvent): ConvertedEvent => ({
|
||||
...eventScope,
|
||||
...event
|
||||
});
|
||||
|
||||
if (method.startsWith('codex/event/')) {
|
||||
return this.handleWrappedCodexEvent(paramsRecord) ?? events;
|
||||
}
|
||||
|
||||
if (method === 'turn/plan/updated') {
|
||||
return extractPlanUpdate(paramsRecord);
|
||||
return addEventScope(extractPlanUpdate(paramsRecord), eventScope);
|
||||
}
|
||||
|
||||
if (method === 'account/rateLimits/updated') {
|
||||
@@ -321,16 +521,17 @@ export class AppServerEventConverter {
|
||||
}
|
||||
|
||||
if (method === 'thread/compacted') {
|
||||
const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id);
|
||||
const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id ?? eventScope.thread_id);
|
||||
if (!threadId) {
|
||||
return events;
|
||||
}
|
||||
const turnId = asString(paramsRecord.turnId ?? paramsRecord.turn_id);
|
||||
const turnId = asString(paramsRecord.turnId ?? paramsRecord.turn_id ?? eventScope.turn_id);
|
||||
events.push({
|
||||
type: 'thread_compacted',
|
||||
thread_id: threadId,
|
||||
...(turnId ? { turn_id: turnId } : {})
|
||||
});
|
||||
events.push(scoped({ type: 'context_compacted' }));
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -351,12 +552,12 @@ export class AppServerEventConverter {
|
||||
if (statusType === 'systemError') {
|
||||
const error = asString(status?.message ?? status?.error ?? paramsRecord.message ?? paramsRecord.error)
|
||||
?? 'Codex thread entered systemError';
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'task_failed',
|
||||
...(threadId ? { thread_id: threadId } : {}),
|
||||
terminal_source: 'thread_status',
|
||||
error
|
||||
});
|
||||
}));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -364,7 +565,7 @@ export class AppServerEventConverter {
|
||||
if (method === 'turn/started') {
|
||||
const turn = asRecord(paramsRecord.turn) ?? paramsRecord;
|
||||
const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id);
|
||||
events.push({ type: 'task_started', ...(turnId ? { turn_id: turnId } : {}) });
|
||||
events.push(scoped({ type: 'task_started', ...(turnId ? { turn_id: turnId } : {}) }));
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -376,30 +577,30 @@ export class AppServerEventConverter {
|
||||
const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason);
|
||||
|
||||
if (status === 'interrupted' || status === 'cancelled' || status === 'canceled') {
|
||||
events.push({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) });
|
||||
events.push(scoped({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) }));
|
||||
return events;
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'error') {
|
||||
events.push({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) });
|
||||
events.push(scoped({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) }));
|
||||
return events;
|
||||
}
|
||||
|
||||
events.push({ type: 'task_complete', ...(turnId ? { turn_id: turnId } : {}) });
|
||||
events.push(scoped({ type: 'task_complete', ...(turnId ? { turn_id: turnId } : {}) }));
|
||||
return events;
|
||||
}
|
||||
|
||||
if (method === 'turn/diff/updated') {
|
||||
const diff = asString(paramsRecord.diff ?? paramsRecord.unified_diff ?? paramsRecord.unifiedDiff);
|
||||
if (diff) {
|
||||
events.push({ type: 'turn_diff', unified_diff: diff });
|
||||
events.push(scoped({ type: 'turn_diff', unified_diff: diff }));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if (method === 'thread/tokenUsage/updated') {
|
||||
const info = asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {};
|
||||
events.push({ type: 'token_count', info });
|
||||
events.push(scoped({ type: 'token_count', info }));
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -408,7 +609,7 @@ export class AppServerEventConverter {
|
||||
if (willRetry) return events;
|
||||
const message = asString(paramsRecord.message) ?? asString(asRecord(paramsRecord.error)?.message);
|
||||
if (message) {
|
||||
events.push({ type: 'task_failed', error: message });
|
||||
events.push(scoped({ type: 'task_failed', error: message }));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -439,7 +640,7 @@ export class AppServerEventConverter {
|
||||
this.lastReasoningDeltaByItemId.set(itemId, delta);
|
||||
const prev = this.reasoningBuffers.get(itemId) ?? '';
|
||||
this.reasoningBuffers.set(itemId, prev + delta);
|
||||
events.push({ type: 'agent_reasoning_delta', delta });
|
||||
events.push(scoped({ type: 'agent_reasoning_delta', delta }));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -454,7 +655,7 @@ export class AppServerEventConverter {
|
||||
}
|
||||
this.reasoningSectionBreakKeys.add(key);
|
||||
}
|
||||
events.push({ type: 'agent_reasoning_section_break' });
|
||||
events.push(scoped({ type: 'agent_reasoning_section_break' }));
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -491,7 +692,7 @@ export class AppServerEventConverter {
|
||||
}
|
||||
const text = extractItemText(item) ?? this.agentMessageBuffers.get(itemId);
|
||||
if (text) {
|
||||
events.push({ type: 'agent_message', message: text });
|
||||
events.push(scoped({ type: 'agent_message', message: text }));
|
||||
this.completedAgentMessageItems.add(itemId);
|
||||
this.agentMessageBuffers.delete(itemId);
|
||||
}
|
||||
@@ -507,7 +708,7 @@ export class AppServerEventConverter {
|
||||
}
|
||||
const text = extractReasoningText(item) ?? this.reasoningBuffers.get(itemId);
|
||||
if (text) {
|
||||
events.push({ type: 'agent_reasoning', text });
|
||||
events.push(scoped({ type: 'agent_reasoning', text }));
|
||||
this.completedReasoningItems.add(itemId);
|
||||
this.reasoningBuffers.delete(itemId);
|
||||
}
|
||||
@@ -527,11 +728,11 @@ export class AppServerEventConverter {
|
||||
if (autoApproved !== null) meta.auto_approved = autoApproved;
|
||||
this.commandMeta.set(itemId, meta);
|
||||
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'exec_command_begin',
|
||||
call_id: itemId,
|
||||
...meta
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (method === 'item/completed') {
|
||||
@@ -542,7 +743,7 @@ export class AppServerEventConverter {
|
||||
const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode);
|
||||
const status = asString(item.status);
|
||||
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'exec_command_end',
|
||||
call_id: itemId,
|
||||
...meta,
|
||||
@@ -551,7 +752,7 @@ export class AppServerEventConverter {
|
||||
...(error ? { error } : {}),
|
||||
...(exitCode !== null ? { exit_code: exitCode } : {}),
|
||||
...(status ? { status } : {})
|
||||
});
|
||||
}));
|
||||
|
||||
this.commandMeta.delete(itemId);
|
||||
this.commandOutputBuffers.delete(itemId);
|
||||
@@ -567,7 +768,7 @@ export class AppServerEventConverter {
|
||||
const input = item.arguments ?? item.input ?? {};
|
||||
|
||||
if (method === 'item/started') {
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'mcp_tool_call_begin',
|
||||
call_id: itemId,
|
||||
server,
|
||||
@@ -577,18 +778,45 @@ export class AppServerEventConverter {
|
||||
tool,
|
||||
arguments: input
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (method === 'item/completed') {
|
||||
const error = item.error;
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: itemId,
|
||||
server,
|
||||
tool,
|
||||
result: error ? { Err: error } : item.result
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
if (itemType === 'collabagenttoolcall') {
|
||||
const toolName = normalizeCollabAgentToolName(item.tool ?? item.name);
|
||||
if (!toolName) return events;
|
||||
|
||||
if (method === 'item/started') {
|
||||
events.push(scoped({
|
||||
type: 'codex_tool_call_begin',
|
||||
call_id: itemId,
|
||||
name: toolName,
|
||||
input: buildCollabAgentInput(item, toolName)
|
||||
}));
|
||||
}
|
||||
|
||||
if (method === 'item/completed') {
|
||||
const status = asString(item.status);
|
||||
events.push(scoped({
|
||||
type: 'codex_tool_call_end',
|
||||
call_id: itemId,
|
||||
name: toolName,
|
||||
output: buildCollabAgentOutput(item, toolName),
|
||||
is_error: status === 'failed' || status === 'error'
|
||||
}));
|
||||
}
|
||||
|
||||
return events;
|
||||
@@ -603,11 +831,11 @@ export class AppServerEventConverter {
|
||||
if (autoApproved !== null) meta.auto_approved = autoApproved;
|
||||
this.fileChangeMeta.set(itemId, meta);
|
||||
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'patch_apply_begin',
|
||||
call_id: itemId,
|
||||
...meta
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (method === 'item/completed') {
|
||||
@@ -616,14 +844,14 @@ export class AppServerEventConverter {
|
||||
const stderr = asString(item.stderr);
|
||||
const success = asBoolean(item.success ?? item.ok ?? item.applied ?? item.status === 'completed');
|
||||
|
||||
events.push({
|
||||
events.push(scoped({
|
||||
type: 'patch_apply_end',
|
||||
call_id: itemId,
|
||||
...meta,
|
||||
...(stdout ? { stdout } : {}),
|
||||
...(stderr ? { stderr } : {}),
|
||||
success: success ?? false
|
||||
});
|
||||
}));
|
||||
|
||||
this.fileChangeMeta.delete(itemId);
|
||||
}
|
||||
|
||||
@@ -77,4 +77,81 @@ describe('registerAppServerPermissionHandlers', () => {
|
||||
decision: 'cancel'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts MCP elicitation requests with schema defaults', async () => {
|
||||
const { client, handlers } = createClient();
|
||||
const permissionHandler = {
|
||||
handleToolCall: vi.fn()
|
||||
};
|
||||
|
||||
registerAppServerPermissionHandlers({
|
||||
client: client as never,
|
||||
permissionHandler: permissionHandler as never
|
||||
});
|
||||
|
||||
const handler = handlers.get('mcpServer/elicitation/request');
|
||||
expect(handler).toBeTypeOf('function');
|
||||
|
||||
await expect(handler?.({
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
serverName: 'hapi',
|
||||
mode: 'form',
|
||||
message: 'Approve MCP tool call?',
|
||||
_meta: null,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
approval: {
|
||||
type: 'string',
|
||||
enum: ['allow', 'deny']
|
||||
},
|
||||
remember: {
|
||||
type: 'boolean',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: ['approval', 'remember']
|
||||
}
|
||||
})).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
approval: 'allow',
|
||||
remember: false
|
||||
},
|
||||
_meta: null
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels non-HAPI MCP elicitation requests', async () => {
|
||||
const { client, handlers } = createClient();
|
||||
const permissionHandler = {
|
||||
handleToolCall: vi.fn()
|
||||
};
|
||||
|
||||
registerAppServerPermissionHandlers({
|
||||
client: client as never,
|
||||
permissionHandler: permissionHandler as never
|
||||
});
|
||||
|
||||
const handler = handlers.get('mcpServer/elicitation/request');
|
||||
expect(handler).toBeTypeOf('function');
|
||||
|
||||
await expect(handler?.({
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
serverName: 'external',
|
||||
mode: 'form',
|
||||
message: 'Collect data',
|
||||
_meta: null,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
})).resolves.toEqual({
|
||||
action: 'cancel',
|
||||
content: null,
|
||||
_meta: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,14 @@ type PermissionResult = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type ElicitationSchemaProperty = {
|
||||
type?: unknown;
|
||||
default?: unknown;
|
||||
enum?: unknown;
|
||||
oneOf?: unknown;
|
||||
items?: unknown;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
@@ -34,6 +42,86 @@ function mapDecision(decision: PermissionDecision): { decision: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function firstString(values: unknown): string | undefined {
|
||||
if (!Array.isArray(values)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return values.find((value): value is string => typeof value === 'string');
|
||||
}
|
||||
|
||||
function firstConst(values: unknown): string | undefined {
|
||||
if (!Array.isArray(values)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
const record = asRecord(value);
|
||||
if (typeof record?.const === 'string') {
|
||||
return record.const;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function defaultValueForElicitationProperty(property: ElicitationSchemaProperty): unknown {
|
||||
if ('default' in property) {
|
||||
return property.default;
|
||||
}
|
||||
|
||||
switch (property.type) {
|
||||
case 'string':
|
||||
return firstString(property.enum)
|
||||
?? firstConst(property.oneOf)
|
||||
?? '';
|
||||
case 'boolean':
|
||||
return true;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return 0;
|
||||
case 'array': {
|
||||
const items = asRecord(property.items);
|
||||
const value = firstString(items?.enum)
|
||||
?? firstConst(items?.anyOf);
|
||||
return value ? [value] : [];
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAcceptedElicitationContent(params: unknown): Record<string, unknown> {
|
||||
const record = asRecord(params);
|
||||
const schema = asRecord(record?.requestedSchema);
|
||||
const properties = asRecord(schema?.properties);
|
||||
|
||||
if (!properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const required = Array.isArray(schema?.required)
|
||||
? schema.required.filter((value): value is string => typeof value === 'string')
|
||||
: Object.keys(properties);
|
||||
const content: Record<string, unknown> = {};
|
||||
|
||||
for (const key of required) {
|
||||
const property = asRecord(properties[key]);
|
||||
if (!property) {
|
||||
continue;
|
||||
}
|
||||
|
||||
content[key] = defaultValueForElicitationProperty(property);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function isHapiBridgeElicitation(params: unknown): boolean {
|
||||
const record = asRecord(params);
|
||||
return record?.serverName === 'hapi';
|
||||
}
|
||||
|
||||
export function registerAppServerPermissionHandlers(args: {
|
||||
client: CodexAppServerClient;
|
||||
permissionHandler: CodexPermissionHandler;
|
||||
@@ -102,4 +190,34 @@ export function registerAppServerPermissionHandlers(args: {
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
client.registerRequestHandler('mcpServer/elicitation/request', async (params) => {
|
||||
const record = asRecord(params) ?? {};
|
||||
|
||||
if (!isHapiBridgeElicitation(params)) {
|
||||
logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', {
|
||||
serverName: record.serverName,
|
||||
mode: record.mode,
|
||||
message: record.message
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'cancel',
|
||||
content: null,
|
||||
_meta: null
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('[CodexAppServer] Accepting MCP elicitation request', {
|
||||
serverName: record.serverName,
|
||||
mode: record.mode,
|
||||
message: record.message
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'accept',
|
||||
content: buildAcceptedElicitationContent(params),
|
||||
_meta: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface HapiMcpBridge {
|
||||
mcpServers: McpServersConfig;
|
||||
}
|
||||
|
||||
export interface HapiMcpBridgeOptions {
|
||||
emitTitleSummary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hapi MCP bridge server and return the configuration
|
||||
* needed to connect Codex to it.
|
||||
@@ -42,8 +46,13 @@ export interface HapiMcpBridge {
|
||||
* This is the single source of truth for MCP bridge setup,
|
||||
* used by both local and remote launchers.
|
||||
*/
|
||||
export async function buildHapiMcpBridge(client: ApiSessionClient): Promise<HapiMcpBridge> {
|
||||
const happyServer = await startHappyServer(client);
|
||||
export async function buildHapiMcpBridge(
|
||||
client: ApiSessionClient,
|
||||
options: HapiMcpBridgeOptions = {}
|
||||
): Promise<HapiMcpBridge> {
|
||||
const happyServer = await startHappyServer(client, {
|
||||
emitTitleSummary: options.emitTitleSummary
|
||||
});
|
||||
const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user