Skip to content

Commit a750d39

Browse files
authored
fix(agent): show Codex subagent activity (#3523)
1 parent 551f9c3 commit a750d39

7 files changed

Lines changed: 514 additions & 24 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 201 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe("CodexAppServerAgent", () => {
143143
});
144144
});
145145

146-
it("isolates subagent output, usage, compaction, and completion", async () => {
146+
it("surfaces subagent activity while isolating its lifecycle state", async () => {
147147
const stub = makeStubRpc({
148148
initialize: {},
149149
"thread/start": { thread: { id: "thr_1" } },
@@ -191,7 +191,6 @@ describe("CodexAppServerAgent", () => {
191191
prompt: "Review the implementation",
192192
},
193193
});
194-
const sessionUpdateCount = sessionUpdates.length;
195194
const extNotificationCount = extNotifications.length;
196195

197196
stub.emit("item/agentMessage/delta", {
@@ -215,6 +214,16 @@ describe("CodexAppServerAgent", () => {
215214
text: '{"source":"child"}',
216215
},
217216
});
217+
stub.emit("item/started", {
218+
threadId: "subagent_1",
219+
turnId: "subagent_turn_1",
220+
item: {
221+
type: "commandExecution",
222+
id: "shared_command_id",
223+
command: "echo child",
224+
status: "inProgress",
225+
},
226+
});
218227
stub.emit("item/commandExecution/outputDelta", {
219228
threadId: "subagent_1",
220229
turnId: "subagent_turn_1",
@@ -251,11 +260,9 @@ describe("CodexAppServerAgent", () => {
251260
expect({
252261
extNotifications: extNotifications.length,
253262
promptSettled,
254-
sessionUpdates: sessionUpdates.length,
255263
}).toEqual({
256264
extNotifications: extNotificationCount,
257265
promptSettled: false,
258-
sessionUpdates: sessionUpdateCount,
259266
});
260267

261268
stub.emit("item/agentMessage/delta", {
@@ -292,10 +299,14 @@ describe("CodexAppServerAgent", () => {
292299
await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" });
293300
const serializedUpdates = JSON.stringify(sessionUpdates);
294301
expect(serializedUpdates).toContain("spawn_agent");
302+
expect(serializedUpdates).toContain("subagent prose");
303+
expect(serializedUpdates).toContain("subagent reasoning");
304+
expect(serializedUpdates).toContain("child command output");
305+
expect(serializedUpdates).toContain(
306+
"subagent:subagent_1:shared_command_id",
307+
);
308+
expect(serializedUpdates).toContain('"parentToolCallId":"spawn_1"');
295309
expect(serializedUpdates).toContain("parent response");
296-
expect(serializedUpdates).not.toContain("subagent prose");
297-
expect(serializedUpdates).not.toContain("subagent reasoning");
298-
expect(serializedUpdates).not.toContain("child command output");
299310
expect(structuredOutputs).toEqual([{ source: "parent" }]);
300311
expect(
301312
extNotifications.filter(
@@ -304,6 +315,142 @@ describe("CodexAppServerAgent", () => {
304315
).toHaveLength(1);
305316
});
306317

318+
it.each(["resumeAgent", "sendInput"])(
319+
"attaches child activity to the current %s call",
320+
async (collaborationTool) => {
321+
let turnNumber = 0;
322+
const stub = makeStubRpc({
323+
initialize: {},
324+
"thread/start": { thread: { id: "thr_1" } },
325+
"turn/start": () => ({
326+
turn: {
327+
id: `turn_${++turnNumber}`,
328+
status: "inProgress",
329+
},
330+
}),
331+
});
332+
const { client, sessionUpdates } = makeFakeClient();
333+
const agent = new CodexAppServerAgent(client, {
334+
processOptions: { binaryPath: "/bundle/codex" },
335+
model: "gpt-5.5",
336+
rpcFactory: stub.factory,
337+
});
338+
339+
await agent.initialize(init);
340+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
341+
342+
const firstPrompt = agent.prompt({
343+
sessionId: "thr_1",
344+
prompt: [{ type: "text", text: "spawn" }],
345+
} as unknown as PromptRequest);
346+
stub.emit("item/started", {
347+
threadId: "thr_1",
348+
turnId: "turn_1",
349+
item: {
350+
type: "collabAgentToolCall",
351+
id: "spawn_1",
352+
tool: "spawnAgent",
353+
receiverThreadIds: ["subagent_1"],
354+
status: "inProgress",
355+
},
356+
});
357+
stub.emit("turn/completed", {
358+
threadId: "thr_1",
359+
turn: { id: "turn_1", status: "completed" },
360+
});
361+
await firstPrompt;
362+
363+
const secondPrompt = agent.prompt({
364+
sessionId: "thr_1",
365+
prompt: [{ type: "text", text: "continue" }],
366+
} as unknown as PromptRequest);
367+
const currentCallId = `${collaborationTool}_1`;
368+
stub.emit("item/started", {
369+
threadId: "thr_1",
370+
turnId: "turn_2",
371+
item: {
372+
type: "collabAgentToolCall",
373+
id: currentCallId,
374+
tool: collaborationTool,
375+
receiverThreadIds: ["subagent_1"],
376+
status: "inProgress",
377+
},
378+
});
379+
stub.emit("item/agentMessage/delta", {
380+
threadId: "subagent_1",
381+
turnId: "subagent_turn_2",
382+
itemId: "message_2",
383+
delta: "continued work",
384+
});
385+
386+
expect(JSON.stringify(sessionUpdates)).toContain(
387+
`"parentToolCallId":"${currentCallId}"`,
388+
);
389+
390+
stub.emit("turn/completed", {
391+
threadId: "thr_1",
392+
turn: { id: "turn_2", status: "completed" },
393+
});
394+
await secondPrompt;
395+
},
396+
);
397+
398+
it("buffers child activity until its parent tool call arrives", async () => {
399+
const stub = makeStubRpc({
400+
initialize: {},
401+
"thread/start": { thread: { id: "thr_1" } },
402+
"turn/start": { turn: { id: "turn_1", status: "inProgress" } },
403+
});
404+
const { client, sessionUpdates } = makeFakeClient();
405+
const agent = new CodexAppServerAgent(client, {
406+
processOptions: { binaryPath: "/bundle/codex" },
407+
model: "gpt-5.5",
408+
rpcFactory: stub.factory,
409+
});
410+
411+
await agent.initialize(init);
412+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
413+
const promptDone = agent.prompt({
414+
sessionId: "thr_1",
415+
prompt: [{ type: "text", text: "delegate" }],
416+
} as unknown as PromptRequest);
417+
418+
stub.emit("item/agentMessage/delta", {
419+
threadId: "subagent_1",
420+
turnId: "subagent_turn_1",
421+
itemId: "message_1",
422+
delta: "early child activity",
423+
});
424+
expect(JSON.stringify(sessionUpdates)).not.toContain(
425+
"early child activity",
426+
);
427+
428+
stub.emit("item/started", {
429+
threadId: "thr_1",
430+
turnId: "turn_1",
431+
item: {
432+
type: "collabAgentToolCall",
433+
id: "spawn_1",
434+
tool: "spawnAgent",
435+
receiverThreadIds: ["subagent_1"],
436+
status: "inProgress",
437+
},
438+
});
439+
440+
const serializedUpdates = JSON.stringify(sessionUpdates);
441+
expect(serializedUpdates).toContain("early child activity");
442+
expect(serializedUpdates).toContain('"parentToolCallId":"spawn_1"');
443+
expect(serializedUpdates.indexOf("spawn_agent")).toBeLessThan(
444+
serializedUpdates.indexOf("early child activity"),
445+
);
446+
447+
stub.emit("turn/completed", {
448+
threadId: "thr_1",
449+
turn: { id: "turn_1", status: "completed" },
450+
});
451+
await promptDone;
452+
});
453+
307454
it.each([
308455
{
309456
label: "reads an empty goal",
@@ -2492,6 +2639,53 @@ describe("CodexAppServerAgent", () => {
24922639
});
24932640
});
24942641

2642+
it("restores subagent relationships from resumed thread history", async () => {
2643+
const stub = makeStubRpc({
2644+
initialize: {},
2645+
"thread/resume": {
2646+
thread: {
2647+
id: "t1",
2648+
turns: [
2649+
{
2650+
items: [
2651+
{
2652+
type: "collabAgentToolCall",
2653+
id: "spawn_1",
2654+
tool: "spawnAgent",
2655+
receiverThreadIds: ["subagent_1"],
2656+
status: "completed",
2657+
},
2658+
],
2659+
},
2660+
],
2661+
},
2662+
},
2663+
});
2664+
const { client, sessionUpdates } = makeFakeClient();
2665+
const agent = new CodexAppServerAgent(client, {
2666+
processOptions: { binaryPath: "/x/codex" },
2667+
model: "gpt-5.5",
2668+
rpcFactory: stub.factory,
2669+
});
2670+
await agent.initialize(init);
2671+
await agent.resumeSession({
2672+
sessionId: "t1",
2673+
cwd: "/r",
2674+
mcpServers: [],
2675+
} as unknown as Parameters<typeof agent.resumeSession>[0]);
2676+
2677+
stub.emit("item/agentMessage/delta", {
2678+
threadId: "subagent_1",
2679+
turnId: "subagent_turn_1",
2680+
itemId: "message_1",
2681+
delta: "still working",
2682+
});
2683+
2684+
expect(JSON.stringify(sessionUpdates)).toContain(
2685+
'"parentToolCallId":"spawn_1"',
2686+
);
2687+
});
2688+
24952689
it("forwards additionalDirectories to thread/start as writable_roots", async () => {
24962690
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
24972691
const { client } = makeFakeClient();

0 commit comments

Comments
 (0)