diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 181d7eca5b..427f279bea 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -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: () => ({ diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 4e52ea9f50..5ca79641f9 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -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[] { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 7c5c46b6a4..61a86ccb8f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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[] = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5b8569320a..75370f0b5b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3485,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession { private compactionTriggerByItemId = new Map(); private pendingRootCompactionItemIds = new Set(); 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(); // 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; } diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts index 2d5d529160..6819292d33 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts @@ -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: "", + }); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts index aa9be4bda7..60ad291058 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts @@ -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>(); + private readonly firstChildren = new Map(); 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(); - 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); } }