fix(server): settle child compactions and keep sub-agent cards stable

- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.
This commit is contained in:
Mohamed Boudra
2026-10-30 11:47:33 +01:00
parent 6953ba6506
commit c1bcfd930b
6 changed files with 165 additions and 22 deletions
@@ -38,6 +38,46 @@ describe("ClaudeSidechainTracker", () => {
expect(childCallIds(next)).toEqual(["next"]);
});
it("shows a streamed child tool call's input once the assistant message brings it", () => {
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
const frames = [
{
type: "stream_event",
parent_tool_use_id: "task-1",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
},
},
{
type: "assistant",
parent_tool_use_id: "task-1",
message: {
content: [
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
],
},
},
] as unknown as SDKMessage[];
const childCards = frames
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(childCards.at(-1)).toMatchObject({
callId: "bash-1",
status: "running",
detail: { type: "shell", command: "echo child" },
});
});
it("uses Claude's native agent name for the provider subagent title", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({
@@ -402,13 +402,12 @@ export class ClaudeSidechainTracker {
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
const input =
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
? block.input
: null;
return [{ key, toolName: block.name, input }];
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
@@ -4872,6 +4872,54 @@ describe("Codex app-server provider", () => {
]);
});
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
for (const child of ["compacted-child", "turn-ended-child"]) {
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: `spawn-${child}`,
kind: "started",
agentThreadId: child,
agentPath: `/root/${child}`,
},
});
await asInternals(session).handleNotification("item/started", {
threadId: child,
item: { type: "contextCompaction", id: `${child}-compaction` },
});
}
await asInternals(session).handleNotification("thread/compacted", {
threadId: "compacted-child",
});
await asInternals(session).handleNotification("item/completed", {
threadId: "compacted-child",
item: { type: "contextCompaction", id: "compacted-child-compaction" },
});
await asInternals(session).handleNotification("turn/completed", {
threadId: "turn-ended-child",
turn: { status: "completed" },
});
const compactions = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline"
? [
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
]
: [],
);
expect(compactions).toEqual([
"compacted-child loading",
"turn-ended-child loading",
"compacted-child completed",
"turn-ended-child completed",
]);
});
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -3485,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession {
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
private pendingRootCompactionItemIds = new Set<string>();
private pendingAnonymousRootCompactions = 0;
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
// `thread/compacted` or the end of the child's turn.
private pendingChildCompactionThreadIds = new Set<string>();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
@@ -5514,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession {
case "patch_apply_completed":
this.handlePatchApplyCompletedNotification(parsed, callId);
return;
case "context_compacted":
this.settleChildCompaction(parsed.threadId);
return;
default:
// Aggregate child telemetry is redundant and must not leak into the
// root timeline. Concrete legacy tools are projected above for Codex
@@ -5797,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession {
): boolean {
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"completed",
)
) {
return true;
}
@@ -5989,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession {
private handleSubAgentContextCompactionItem(
callId: string,
childThreadId: string,
item: { id?: string; type?: string; [key: string]: unknown },
status: "loading" | "completed",
): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
for (const childThreadId of this.subAgentCallsByCallId.get(callId)?.childThreadIds ?? []) {
if (status === "loading") {
this.pendingChildCompactionThreadIds.add(childThreadId);
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
} else {
this.settleChildCompaction(childThreadId);
}
this.emitSubAgentActivityUpdate(callId);
return true;
}
private settleChildCompaction(childThreadId: string): void {
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
}
}
private shouldSkipCompletedThreadItem(
timelineItem: AgentTimelineItem,
normalizedItemType: string | undefined,
@@ -6132,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
let status: ToolCallTimelineItem["status"] = "completed";
if (parsed.status === "failed") {
status = "failed";
@@ -6718,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession {
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"loading",
)
) {
return;
}
@@ -112,4 +112,31 @@ describe("OmpSubagentCardTracker", () => {
log: "",
});
});
test("leaves the card alone when a later child of the same task starts", () => {
const emitted: string[] = [];
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
tracker.handleLifecycle(
{
...LIFECYCLE,
id: "Second",
index: 1,
description: "Second child",
sessionFile: "/s.jsonl",
},
emit,
);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
description: "Run echo in subagent",
childSessionId: SESSION_FILE,
log: "",
});
});
});
@@ -9,10 +9,11 @@ interface OmpSubagentCardChild {
/**
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
* launch marker; the child's activity belongs to the sub-agent's own timeline.
* launch marker that names the task's first child; the children's activity belongs to their own
* timelines.
*/
export class OmpSubagentCardTracker {
private readonly children = new Map<string, Map<number, OmpSubagentCardChild>>();
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
@@ -48,10 +49,7 @@ export class OmpSubagentCardTracker {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const children = this.children.get(toolCallId);
const first = children
? [...children.values()].sort((left, right) => left.index - right.index)[0]
: undefined;
const first = this.firstChildren.get(toolCallId);
if (!first) {
return baseDetail;
}
@@ -65,11 +63,11 @@ export class OmpSubagentCardTracker {
}
delete(toolCallId: string): void {
this.children.delete(toolCallId);
this.firstChildren.delete(toolCallId);
}
clear(): void {
this.children.clear();
this.firstChildren.clear();
}
private update(
@@ -81,9 +79,11 @@ export class OmpSubagentCardTracker {
if (!toolCallId) {
return;
}
const children = this.children.get(toolCallId) ?? new Map<number, OmpSubagentCardChild>();
this.children.set(toolCallId, children);
const existing = children.get(input.index);
const first = this.firstChildren.get(toolCallId);
if (first && input.index > first.index) {
return;
}
const existing = first?.index === input.index ? first : undefined;
const next: OmpSubagentCardChild = {
index: input.index,
description: readTrimmedString(input.description) ?? existing?.description,
@@ -96,7 +96,7 @@ export class OmpSubagentCardTracker {
) {
return;
}
children.set(input.index, next);
this.firstChildren.set(toolCallId, next);
emitToolCall(toolCallId);
}
}