Show friendly native subagent names and hide finished work (#2073)
* feat(subagents): archive finished children and show native names Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them. * fix(subagents): cover persistence and naming edge cases * fix(subagents): preserve active descendants and native names * fix(subagents): retain dismissed timelines and trim paths * fix(subagents): retain history across hydration * test(subagents): expect normalized native descriptions * fix(subagents): separate hidden rows from retained history * fix(subagents): preserve restored hidden tabs safely * fix(subagents): integrate archive with run state * test(codex): expect normalized child name * fix(subagents): retain dismissed tabs on reload * fix(subagents): hide finished native agents locally Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol. * fix(subagents): humanize native agent names * fix(subagents): preserve names in Codex history * fix(subagents): retain hidden rows across reloads
This commit is contained in:
@@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] {
|
||||
}
|
||||
|
||||
interface ClaudeHistoricalSubagentToolCall {
|
||||
name?: string;
|
||||
subagentType?: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls(
|
||||
continue;
|
||||
}
|
||||
const input = toObjectRecord(block.input);
|
||||
const name = readNonEmptyString(input?.name);
|
||||
const subagentType = readNonEmptyString(input?.subagent_type);
|
||||
const description = readNonEmptyString(input?.description);
|
||||
toolCalls.set(block.id, {
|
||||
...(name ? { name } : {}),
|
||||
...(subagentType ? { subagentType } : {}),
|
||||
...(description ? { description } : {}),
|
||||
});
|
||||
@@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents(
|
||||
return events;
|
||||
}
|
||||
|
||||
function resolveClaudeHistoricalSubagentTitle(
|
||||
toolCall: ClaudeHistoricalSubagentToolCall | undefined,
|
||||
): string {
|
||||
return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent";
|
||||
}
|
||||
|
||||
function buildClaudePersistedSidechainAgentEvents(
|
||||
agentId: string,
|
||||
entries: ClaudeHistoryEntry[],
|
||||
@@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents(
|
||||
event: {
|
||||
type: "upsert",
|
||||
id,
|
||||
title: toolCall?.subagentType ?? "Claude subagent",
|
||||
title: resolveClaudeHistoricalSubagentTitle(toolCall),
|
||||
description: toolCall?.description ?? null,
|
||||
status: "running",
|
||||
toolCallId: result?.toolCallId ?? null,
|
||||
|
||||
+15
-1
@@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
type: "tool_use",
|
||||
id: "history-task-call",
|
||||
name: "Agent",
|
||||
input: { description: "Inspect persisted history" },
|
||||
input: {
|
||||
name: "history_researcher",
|
||||
subagent_type: "Explore",
|
||||
description: "Inspect persisted history",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
},
|
||||
});
|
||||
expect(historyEvents).toContainEqual({
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
event: expect.objectContaining({
|
||||
type: "upsert",
|
||||
id: "history-task-call",
|
||||
title: "history_researcher",
|
||||
status: "running",
|
||||
}),
|
||||
});
|
||||
expect(historyEvents).toContainEqual({
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
|
||||
describe("ClaudeSidechainTracker", () => {
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({
|
||||
name: "repo_researcher",
|
||||
subagent_type: "Explore",
|
||||
description: "Inspect the repository",
|
||||
}),
|
||||
});
|
||||
|
||||
const events = tracker.handleMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [] },
|
||||
} as unknown as SDKMessage,
|
||||
"task-1",
|
||||
);
|
||||
|
||||
expect(events[0]).toEqual({
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "task-1",
|
||||
title: "repo_researcher",
|
||||
description: "Inspect the repository",
|
||||
status: "running",
|
||||
toolCallId: "task-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ interface SubAgentActionEntry {
|
||||
}
|
||||
|
||||
interface SubAgentActivityState {
|
||||
name?: string;
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
actions: SubAgentActionEntry[];
|
||||
@@ -130,7 +131,7 @@ export class ClaudeSidechainTracker {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: parentToolUseId,
|
||||
title: state.subAgentType ?? "Claude subagent",
|
||||
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||
description: state.description ?? null,
|
||||
status: "running",
|
||||
toolCallId: parentToolUseId,
|
||||
@@ -163,7 +164,7 @@ export class ClaudeSidechainTracker {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id,
|
||||
title: state.subAgentType ?? "Claude subagent",
|
||||
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||
description: state.description ?? null,
|
||||
status,
|
||||
toolCallId: id,
|
||||
@@ -185,7 +186,7 @@ export class ClaudeSidechainTracker {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id,
|
||||
title: state.subAgentType ?? "Claude subagent",
|
||||
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||
description: state.description ?? null,
|
||||
status,
|
||||
toolCallId: id,
|
||||
@@ -267,10 +268,15 @@ export class ClaudeSidechainTracker {
|
||||
parentToolUseId: string,
|
||||
): boolean {
|
||||
const taskInput = this.getToolInput(parentToolUseId);
|
||||
const nextName = this.normalizeSubAgentText(taskInput?.name);
|
||||
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
||||
|
||||
let changed = false;
|
||||
if (nextName && nextName !== state.name) {
|
||||
state.name = nextName;
|
||||
changed = true;
|
||||
}
|
||||
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
|
||||
state.subAgentType = nextSubAgentType;
|
||||
changed = true;
|
||||
|
||||
@@ -1847,6 +1847,60 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a registered child with its later native activity name", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
id: "call-native-name-later",
|
||||
tool: "spawnAgent",
|
||||
status: "completed",
|
||||
prompt: "Inspect the repository.",
|
||||
receiverThreadIds: ["child-native-name-later"],
|
||||
agentsStates: {
|
||||
"child-native-name-later": { status: "pendingInit", message: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
asInternals(session).handleNotification("item/started", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "subAgentActivity",
|
||||
id: "activity-native-name-later",
|
||||
kind: "started",
|
||||
agentThreadId: "child-native-name-later",
|
||||
agentPath: "/root/research/investigator",
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: expect.objectContaining({
|
||||
type: "upsert",
|
||||
id: "child-native-name-later",
|
||||
title: "Research / Investigator",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
callId: "call-native-name-later",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Research / Investigator",
|
||||
description: "Inspect the repository.",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("renders child MCP image results in the provider subagent timeline", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -2500,7 +2554,7 @@ describe("Codex app-server provider", () => {
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description: "/root/legacy-only-child",
|
||||
description: "legacy-only-child",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2879,6 +2933,13 @@ describe("Codex app-server provider", () => {
|
||||
receiverThreadIds: ["legacy-child-thread"],
|
||||
agentsStates: { "legacy-child-thread": { status: "completed" } },
|
||||
},
|
||||
{
|
||||
type: "subAgentActivity",
|
||||
id: "legacy-native-name-history",
|
||||
kind: "started",
|
||||
agentThreadId: "legacy-child-thread",
|
||||
agentPath: "/root/sentinel_child",
|
||||
},
|
||||
{
|
||||
type: "subAgentActivity",
|
||||
id: "v2-spawn-history",
|
||||
@@ -2905,7 +2966,12 @@ describe("Codex app-server provider", () => {
|
||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||
),
|
||||
).toMatchObject([
|
||||
{ type: "upsert", id: "legacy-child-thread", status: "completed" },
|
||||
{
|
||||
type: "upsert",
|
||||
id: "legacy-child-thread",
|
||||
status: "completed",
|
||||
title: "Sentinel child",
|
||||
},
|
||||
{ type: "upsert", id: "v2-child-thread", status: "completed" },
|
||||
]);
|
||||
expect(
|
||||
@@ -2940,12 +3006,16 @@ describe("Codex app-server provider", () => {
|
||||
{
|
||||
callId: "legacy-spawn-history",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", description: "Legacy child" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description: "Legacy child",
|
||||
subAgentType: "Sentinel child",
|
||||
},
|
||||
},
|
||||
{
|
||||
callId: "v2-spawn-history",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", description: "/root/v2-child" },
|
||||
detail: { type: "sub_agent", description: "v2-child" },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3067,7 +3137,7 @@ describe("Codex app-server provider", () => {
|
||||
status: "canceled",
|
||||
detail: expect.objectContaining({
|
||||
type: "sub_agent",
|
||||
description: "/root/history-child",
|
||||
description: "history-child",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1668,14 +1668,22 @@ function updateHistoricalSubAgentActivity(
|
||||
timeline: PersistedTimelineEntry[],
|
||||
index: number,
|
||||
kind: CodexSubAgentActivity["kind"],
|
||||
subAgentType?: string,
|
||||
): void {
|
||||
const existing = timeline[index];
|
||||
if (existing?.item.type !== "tool_call") {
|
||||
return;
|
||||
}
|
||||
const settledItem = settleHistoricalSubAgentActivity(existing.item, kind);
|
||||
timeline[index] = {
|
||||
...existing,
|
||||
item: settleHistoricalSubAgentActivity(existing.item, kind),
|
||||
item:
|
||||
subAgentType && settledItem.detail.type === "sub_agent"
|
||||
? {
|
||||
...settledItem,
|
||||
detail: { ...settledItem.detail, subAgentType },
|
||||
}
|
||||
: settledItem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1869,10 +1877,15 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -4843,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (activity.id) {
|
||||
state.activityItemIds.add(activity.id);
|
||||
}
|
||||
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (
|
||||
activityToolCall?.detail.type === "sub_agent" &&
|
||||
state.toolCall.detail.type === "sub_agent"
|
||||
) {
|
||||
state.toolCall = {
|
||||
...state.toolCall,
|
||||
detail: {
|
||||
...state.toolCall.detail,
|
||||
subAgentType: activityToolCall.detail.subAgentType,
|
||||
},
|
||||
};
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(
|
||||
callId,
|
||||
activity.kind === "interrupted" ? "canceled" : "running",
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => {
|
||||
id: `activity-${kind}`,
|
||||
kind,
|
||||
agentThreadId: "child-thread-1",
|
||||
agentPath: "/root/investigator",
|
||||
agentPath: "/root/research/investigator",
|
||||
});
|
||||
|
||||
expect(item).toEqual({
|
||||
@@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => {
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "/root/investigator",
|
||||
subAgentType: "Research / Investigator",
|
||||
description: "research/investigator",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
@@ -267,6 +267,60 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("humanizes a subagent task name for display", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "subAgentActivity",
|
||||
id: "activity-human-name",
|
||||
kind: "started",
|
||||
agentThreadId: "child-thread-human-name",
|
||||
agentPath: "/root/hello_one",
|
||||
});
|
||||
|
||||
expect(item).toMatchObject({
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Hello one",
|
||||
description: "hello_one",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses only the final segment of a subAgentActivity path outside the root namespace", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "subAgentActivity",
|
||||
id: "activity-external-path",
|
||||
kind: "started",
|
||||
agentThreadId: "child-thread-external-path",
|
||||
agentPath: "/tmp/native/investigator",
|
||||
});
|
||||
|
||||
expect(item).toMatchObject({
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Investigator",
|
||||
description: "investigator",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses only the final segment of a Windows subAgentActivity path", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "subAgentActivity",
|
||||
id: "activity-windows-path",
|
||||
kind: "started",
|
||||
agentThreadId: "child-thread-windows-path",
|
||||
agentPath: "C:\\Users\\dev\\agents\\investigator",
|
||||
});
|
||||
|
||||
expect(item).toMatchObject({
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Investigator",
|
||||
description: "investigator",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fail a collabAgentToolCall from child error state alone", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
|
||||
@@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem(
|
||||
function mapSubAgentActivityItem(
|
||||
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
|
||||
): ToolCallTimelineItem {
|
||||
let nativeName = item.agentPath;
|
||||
if (nativeName === "/root") {
|
||||
nativeName = "";
|
||||
} else if (nativeName.startsWith("/root/")) {
|
||||
nativeName = nativeName.slice("/root/".length);
|
||||
} else if (/[\\/]/.test(nativeName)) {
|
||||
nativeName = nativeName.slice(
|
||||
Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1,
|
||||
);
|
||||
}
|
||||
const description = nativeName;
|
||||
nativeName = nativeName
|
||||
.split("/")
|
||||
.map((segment) => segment.replace(/[_-]+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
|
||||
.join(" / ");
|
||||
nativeName ||= "Sub-agent";
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: item.id,
|
||||
@@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem(
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: item.agentPath,
|
||||
subAgentType: nativeName,
|
||||
description,
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user