Skip to content

Commit 7b8a583

Browse files
committed
fix(agent): preserve resumed subagent activity
Generated-By: PostHog Code Task-Id: 38f87de0-3672-400f-af61-378f74c17f2b
1 parent 17b0264 commit 7b8a583

6 files changed

Lines changed: 227 additions & 2 deletions

File tree

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,86 @@ describe("CodexAppServerAgent", () => {
315315
).toHaveLength(1);
316316
});
317317

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+
318398
it.each([
319399
{
320400
label: "reads an empty goal",
@@ -2477,6 +2557,53 @@ describe("CodexAppServerAgent", () => {
24772557
});
24782558
});
24792559

2560+
it("restores subagent relationships from resumed thread history", async () => {
2561+
const stub = makeStubRpc({
2562+
initialize: {},
2563+
"thread/resume": {
2564+
thread: {
2565+
id: "t1",
2566+
turns: [
2567+
{
2568+
items: [
2569+
{
2570+
type: "collabAgentToolCall",
2571+
id: "spawn_1",
2572+
tool: "spawnAgent",
2573+
receiverThreadIds: ["subagent_1"],
2574+
status: "completed",
2575+
},
2576+
],
2577+
},
2578+
],
2579+
},
2580+
},
2581+
});
2582+
const { client, sessionUpdates } = makeFakeClient();
2583+
const agent = new CodexAppServerAgent(client, {
2584+
processOptions: { binaryPath: "/x/codex" },
2585+
model: "gpt-5.5",
2586+
rpcFactory: stub.factory,
2587+
});
2588+
await agent.initialize(init);
2589+
await agent.resumeSession({
2590+
sessionId: "t1",
2591+
cwd: "/r",
2592+
mcpServers: [],
2593+
} as unknown as Parameters<typeof agent.resumeSession>[0]);
2594+
2595+
stub.emit("item/agentMessage/delta", {
2596+
threadId: "subagent_1",
2597+
turnId: "subagent_turn_1",
2598+
itemId: "message_1",
2599+
delta: "still working",
2600+
});
2601+
2602+
expect(JSON.stringify(sessionUpdates)).toContain(
2603+
'"parentToolCallId":"spawn_1"',
2604+
);
2605+
});
2606+
24802607
it("forwards additionalDirectories to thread/start as writable_roots", async () => {
24812608
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
24822609
const { client } = makeFakeClient();

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
518518
}
519519
this.threadId = threadId;
520520
this.sessionId = threadId;
521+
this.restoreSubagentRelationships(thread);
521522
if (method === APP_SERVER_METHODS.THREAD_START && params.meta?.nativeGoal) {
522523
await this.restoreGoal(params.meta.nativeGoal);
523524
}
@@ -1325,9 +1326,28 @@ export class CodexAppServerAgent extends BaseAcpAgent {
13251326
return;
13261327
}
13271328
const item = (params as { item?: AppServerItem })?.item;
1329+
this.captureSubagentRelationshipItem(item, senderThreadId);
1330+
}
1331+
1332+
private restoreSubagentRelationships(
1333+
thread: AppServerThread | undefined,
1334+
): void {
1335+
for (const turn of thread?.turns ?? []) {
1336+
for (const item of turn.items ?? []) {
1337+
this.captureSubagentRelationshipItem(item, item.senderThreadId);
1338+
}
1339+
}
1340+
}
1341+
1342+
private captureSubagentRelationshipItem(
1343+
item: AppServerItem | undefined,
1344+
senderThreadId: string | undefined,
1345+
): void {
13281346
if (
13291347
item?.type !== "collabAgentToolCall" ||
1330-
item.tool !== "spawnAgent" ||
1348+
(item.tool !== "spawnAgent" &&
1349+
item.tool !== "resumeAgent" &&
1350+
item.tool !== "sendInput") ||
13311351
!item.id ||
13321352
!item.receiverThreadIds?.length
13331353
) {

packages/shared/src/tool-meta.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ describe("readParentToolCallId", () => {
6767
}),
6868
).toBe("parent-2");
6969
});
70+
71+
it("ignores malformed canonical metadata and uses a valid legacy fallback", () => {
72+
expect(
73+
readParentToolCallId({
74+
posthog: { toolName: "Bash", parentToolCallId: {} },
75+
claudeCode: { parentToolCallId: "parent-3" },
76+
}),
77+
).toBe("parent-3");
78+
});
79+
80+
it("returns undefined for empty or non-string parent ids", () => {
81+
expect(
82+
readParentToolCallId({ posthog: { parentToolCallId: "" } }),
83+
).toBeUndefined();
84+
expect(
85+
readParentToolCallId({ claudeCode: { parentToolCallId: 123 } }),
86+
).toBeUndefined();
87+
});
7088
});
7189

7290
describe("readMcpToolDescriptor / readMcpToolName", () => {

packages/shared/src/tool-meta.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ export function readAgentToolName(meta: unknown): string | undefined {
6363
/** Parent subagent tool call: neutral channel first, legacy fallback. */
6464
export function readParentToolCallId(meta: unknown): string | undefined {
6565
const m = asToolCallMeta(meta);
66-
return m?.posthog?.parentToolCallId ?? m?.claudeCode?.parentToolCallId;
66+
const canonical = m?.posthog?.parentToolCallId;
67+
if (typeof canonical === "string" && canonical.length > 0) return canonical;
68+
const legacy = m?.claudeCode?.parentToolCallId;
69+
return typeof legacy === "string" && legacy.length > 0 ? legacy : undefined;
6770
}
6871

6972
/**

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,17 @@ function isThoughtItem(
174174
}
175175

176176
export function markThoughtCompletion(items: ConversationItem[]) {
177+
markThoughtCompletionInItems(items, new Set());
178+
}
179+
180+
function markThoughtCompletionInItems(
181+
items: ConversationItem[],
182+
visited: Set<ConversationItem[]>,
183+
) {
184+
if (visited.has(items)) return;
185+
visited.add(items);
177186
const seenContexts = new Set<TurnContext>();
187+
const itemContexts = new Set<TurnContext>();
178188

179189
for (let i = items.length - 1; i >= 0; i--) {
180190
const item = items[i];
@@ -186,6 +196,13 @@ export function markThoughtCompletion(items: ConversationItem[]) {
186196

187197
if (item.type === "session_update") {
188198
seenContexts.add(item.turnContext);
199+
itemContexts.add(item.turnContext);
200+
}
201+
}
202+
203+
for (const context of itemContexts) {
204+
for (const children of context.childItems.values()) {
205+
markThoughtCompletionInItems(children, visited);
189206
}
190207
}
191208
}

packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ const childToolCallMsg = (
143143
_meta: { claudeCode: { parentToolCallId } },
144144
});
145145

146+
const childThoughtChunk = (
147+
ts: number,
148+
text: string,
149+
parentToolCallId: string,
150+
) =>
151+
updateMsg(ts, {
152+
sessionUpdate: "agent_thought_chunk",
153+
content: { type: "text", text },
154+
_meta: {
155+
posthog: {
156+
toolName: "subagent_activity",
157+
parentToolCallId,
158+
},
159+
},
160+
});
161+
146162
// --- normalization (cycle-free, Map-resolved) -----------------------------
147163

148164
function normContext(ctx: TurnContext) {
@@ -505,4 +521,28 @@ describe("createIncrementalConversationBuilder", () => {
505521
}
506522
expect(row.turnContext.childItems.get("agent1")?.length).toBe(1);
507523
});
524+
525+
it("marks nested subagent thoughts complete when the turn finishes", () => {
526+
const inc = createIncrementalConversationBuilder();
527+
const messages = [
528+
userPromptMsg(1, 1, "go"),
529+
toolCallMsg(2, "agent1", {
530+
_meta: { posthog: { toolName: "spawn_agent" } },
531+
}),
532+
childThoughtChunk(3, "investigating", "agent1"),
533+
promptResponseMsg(4, 1),
534+
];
535+
536+
const result = inc.update(messages, false);
537+
const row = result.items.find((item) => item.type === "session_update");
538+
if (row?.type !== "session_update") {
539+
throw new Error("expected agent session_update row");
540+
}
541+
const thought = row.turnContext.childItems.get("agent1")?.[0];
542+
expect(thought).toMatchObject({
543+
type: "session_update",
544+
thoughtComplete: true,
545+
update: { sessionUpdate: "agent_thought_chunk" },
546+
});
547+
});
508548
});

0 commit comments

Comments
 (0)