diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index a10bd7b0c6..5e08f944df 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -64,7 +64,7 @@ function resolveShouldFill(
): boolean {
if (!fillAvailableHeight) return false;
const t = detail?.type;
- return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
+ return t === "shell" || t === "edit" || t === "write" || t === "read";
}
function useDetailStyles(
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
-function resolveSubAgentFallbackHeader(
- subAgentType: string | null | undefined,
- description: string | null | undefined,
- fallbackText: string,
-): string {
- if (subAgentType && description) {
- return `${subAgentType}: ${description}`;
- }
- return subAgentType ?? description ?? fallbackText;
-}
-
-interface SubAgentDetailProps {
- log: string;
- childSessionId: string | null | undefined;
- subAgentType: string | null | undefined;
- description: string | null | undefined;
- ds: DetailStyles;
-}
-
-interface SubAgentActivityRow {
- index: number;
- toolName: string;
- summary?: string;
-}
-
-interface ParsedSubAgentLog {
- actions: SubAgentActivityRow[];
- remainingLog: string;
-}
-
-function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
- const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
- if (!match) {
- return null;
- }
- const toolName = match[1]?.trim();
- if (!toolName) {
- return null;
- }
- const summary = match[2]?.trim();
- return {
- index,
- toolName,
- ...(summary ? { summary } : {}),
- };
-}
-
-function parseSubAgentLog(log: string): ParsedSubAgentLog {
- const actions: SubAgentActivityRow[] = [];
- const remainingLines: string[] = [];
- for (const line of log.replace(/^\n+/, "").split("\n")) {
- const normalizedLine = line.trim();
- if (!normalizedLine) {
- continue;
- }
- const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
- if (parsedAction) {
- actions.push(parsedAction);
- } else {
- remainingLines.push(line);
- }
- }
- return {
- actions,
- remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
- };
-}
-
-function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
- return (
-
-
- {formatSubAgentToolName(action.toolName)}
-
- {action.summary ? (
-
- {action.summary}
-
- ) : null}
-
- );
-}
-
-function formatSubAgentToolName(toolName: string): string {
- const trimmed = toolName.trim();
- if (!trimmed) {
- return toolName;
- }
- return trimmed
- .replace(/[._-]+/g, " ")
- .split(" ")
- .filter((segment) => segment.length > 0)
- .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
- .join(" ");
-}
-
-function SubAgentLogText({
- activityLog,
- fallbackHeader,
- hasActions,
-}: {
- activityLog: string;
- fallbackHeader: string;
- hasActions: boolean;
-}) {
- if (activityLog.length > 0) {
- return (
-
- {activityLog}
-
- );
- }
- if (!hasActions) {
- return (
-
- {fallbackHeader}
-
- );
- }
- return null;
-}
-
-function SubAgentDetailSection({
- log,
- childSessionId,
- subAgentType,
- description,
- ds,
-}: SubAgentDetailProps) {
- const { t } = useTranslation();
- const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
- const fallbackHeader = resolveSubAgentFallbackHeader(
- subAgentType,
- description,
- t("toolCallDetails.subAgentActivity"),
- );
- const hasActions = actions.length > 0;
- return (
-
-
-
-
-
- {childSessionId ? (
-
- session {childSessionId}
-
- ) : null}
- {hasActions ? (
-
- {actions.map((action) => (
-
- ))}
-
- ) : null}
-
-
-
-
-
-
- );
-}
-
interface EditDetailProps {
diffLines: DiffLine[] | undefined;
ds: DetailStyles;
@@ -636,19 +458,7 @@ function buildDetailSections(
,
];
}
- if (detail.type === "worktree_setup") return [];
- if (detail.type === "sub_agent") {
- return [
- ,
- ];
- }
+ if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
if (detail.type === "edit") {
return [];
}
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
shellPrompt: {
color: theme.colors.foregroundMuted,
},
- subAgentSessionText: {
- fontFamily: theme.fontFamily.mono,
- fontSize: theme.fontSize.code,
- color: theme.colors.foregroundMuted,
- lineHeight: 18,
- marginBottom: theme.spacing[2],
- },
- subAgentActions: {
- gap: theme.spacing[1],
- marginBottom: theme.spacing[2],
- },
- subAgentActionRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- subAgentActionTool: {
- fontFamily: theme.fontFamily.mono,
- fontSize: theme.fontSize.code,
- color: theme.colors.foregroundMuted,
- lineHeight: 18,
- },
- subAgentActionSummary: {
- fontFamily: theme.fontFamily.mono,
- fontSize: theme.fontSize.code,
- color: theme.colors.foreground,
- lineHeight: 18,
- },
jsonScroll: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts
index 18ba3f041e..b73b2c4fec 100644
--- a/packages/app/src/i18n/resources.test.ts
+++ b/packages/app/src/i18n/resources.test.ts
@@ -832,7 +832,6 @@ describe("translation resources", () => {
expect(en.common.placeholders.search).toBe("Search...");
expect(en.common.empty.noResults).toBe("No results found");
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
- expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
});
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 99d5a11701..7c9e13cce1 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -2013,7 +2013,6 @@ export const ar: TranslationResources = {
toolCallDetails: {
error: "خطأ",
empty: "لا توجد تفاصيل إضافية متاحة",
- subAgentActivity: "نشاط الوكيل الفرعي",
input: "مدخل",
output: "الإخراج",
},
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 636fe42d5a..7022dc9a3d 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -2038,7 +2038,6 @@ export const en = {
toolCallDetails: {
error: "Error",
empty: "No additional details available",
- subAgentActivity: "Sub-agent activity",
input: "Input",
output: "Output",
},
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index abd5320fe9..1c2c07ade0 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -2061,7 +2061,6 @@ export const es: TranslationResources = {
toolCallDetails: {
error: "Error",
empty: "No hay detalles adicionales disponibles",
- subAgentActivity: "Actividad de subagente",
input: "Aporte",
output: "Producción",
},
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 848666b2bb..bf1c3b6f5e 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -2060,7 +2060,6 @@ export const fr: TranslationResources = {
toolCallDetails: {
error: "Erreur",
empty: "Aucun détail supplémentaire disponible",
- subAgentActivity: "Activité du sous-agent",
input: "Entrée",
output: "Sortie",
},
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index eb6423913e..5147134716 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -2033,7 +2033,6 @@ export const ja: TranslationResources = {
toolCallDetails: {
error: "エラー",
empty: "追加の詳細はありません",
- subAgentActivity: "サブエージェントアクティビティ",
input: "入力",
output: "出力",
},
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index 54605df4a6..632bd1eeed 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -2023,7 +2023,6 @@ export const ko: TranslationResources = {
toolCallDetails: {
error: "오류",
empty: "추가 세부 정보가 없습니다",
- subAgentActivity: "서브에이전트 활동",
input: "입력",
output: "출력",
},
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index ecedcb5c9e..a80ded2f45 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = {
toolCallDetails: {
error: "Erro",
empty: "Nenhum detalhe adicional disponível",
- subAgentActivity: "Atividade do subagente",
input: "Entrada",
output: "Saída",
},
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index d9c7afdedd..d4a23a4770 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -2046,7 +2046,6 @@ export const ru: TranslationResources = {
toolCallDetails: {
error: "Ошибка",
empty: "Дополнительные сведения отсутствуют",
- subAgentActivity: "Активность субагента",
input: "Входные данные",
output: "Выходные данные",
},
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index e0b5bbcdb4..2ce5f7bb9e 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = {
toolCallDetails: {
error: "错误",
empty: "没有可用的更多详情",
- subAgentActivity: "Sub-agent 活动",
input: "输入",
output: "输出",
},
diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts
index e2d983d8cb..b28848af8e 100644
--- a/packages/app/src/utils/tool-call-detail-state.ts
+++ b/packages/app/src/utils/tool-call-detail-state.ts
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract,
-): boolean {
- return Boolean(detail.subAgentType || detail.description || detail.log);
-}
-
function hasMeaningfulPlainTextDetail(
detail: Extract,
): boolean {
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
- return false;
+ // A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
case "sub_agent":
- return hasMeaningfulSubAgentDetail(detail);
+ return false;
case "plain_text":
return hasMeaningfulPlainTextDetail(detail);
case "plan":
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
- if (params.detail?.type === "worktree_setup") return false;
+ if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts
index c7fffe8ce9..ca04655430 100644
--- a/packages/protocol/src/agent-types.ts
+++ b/packages/protocol/src/agent-types.ts
@@ -288,7 +288,7 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
- log: string;
+ log?: string;
actions?: Array<{
index: number;
toolName: string;
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index 18c3da9b67..8d8f852988 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -641,7 +641,9 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discri
subAgentType: z.string().optional(),
description: z.string().optional(),
childSessionId: z.string().optional(),
- log: z.string(),
+ // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a
+ // launch marker; apps before v0.14.1 required `log`, so daemons still send it empty.
+ log: z.string().optional(),
// Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still
// emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those
// clients are no longer in the field.
diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts
index d6b2bc02ff..abc89b06aa 100644
--- a/packages/protocol/src/messages.wire-compat.test.ts
+++ b/packages/protocol/src/messages.wire-compat.test.ts
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
});
});
- test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
- const parsed = LegacySubAgentToolCallSchema.parse({
+ test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
+ const card = {
type: "tool_call",
callId: "call-sub-agent-1",
name: "Task",
@@ -212,12 +212,26 @@ describe("wire schema compatibility", () => {
subAgentType: "Explore",
description: "Inspect repository structure",
childSessionId: "child-session-1",
- log: "[Read] README.md",
+ log: "",
actions: [],
},
- });
+ } as const;
- expect(parsed.detail.actions).toEqual([]);
+ const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
+ expect(parsed.detail).toMatchObject({ log: "", actions: [] });
+ const { log: _log, ...withoutLog } = card.detail;
+ expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
+ false,
+ );
+ expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
+ detail: { type: "sub_agent", description: "Inspect repository structure" },
+ });
+ expect(
+ AgentTimelineItemPayloadSchema.parse({
+ ...card,
+ detail: { ...card.detail, log: "[Read] README.md" },
+ }),
+ ).toMatchObject({ detail: { log: "[Read] README.md" } });
});
test("old clients parse agent snapshots with rewind capabilities", () => {
diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts
index c98bbae54d..4e9ce49528 100644
--- a/packages/server/src/server/agent/activity-curator.test.ts
+++ b/packages/server/src/server/agent/activity-curator.test.ts
@@ -247,7 +247,7 @@ second line'`,
]);
});
- it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
+ it("lists each sub-agent as its launch marker", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before first child." },
toolCallItem({
@@ -257,7 +257,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child one",
description: "First investigation",
- log: "[Assistant] First child result.",
+ log: "",
},
}),
{ type: "assistant_message", text: "Between children." },
@@ -268,7 +268,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
- log: "[Assistant] Second child result.",
+ log: "",
},
}),
{ type: "assistant_message", text: "After second child." },
@@ -279,10 +279,8 @@ second line'`,
expect(result.split("\n")).toEqual([
"[Assistant] Before first child.",
"[Child one] First investigation",
- "[Assistant] First child result.",
"[Assistant] Between children.",
"[Child two] Second investigation",
- "[Assistant] Second child result.",
"[Assistant] After second child.",
]);
});
diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts
index 488867c657..2a7cbff5e6 100644
--- a/packages/server/src/server/agent/activity-curator.ts
+++ b/packages/server/src/server/agent/activity-curator.ts
@@ -164,9 +164,6 @@ function curateProjectedActivityEntries(
break;
}
entries.push(formatToolCallEntry(item, options));
- if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
- entries.push(activityEntry(item.detail.log.trim()));
- }
break;
}
case "todo":
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
index 507cfbceb8..6570392311 100644
--- a/packages/server/src/server/agent/agent-sdk-types.ts
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -313,7 +313,10 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
- log: string;
+ // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
+ // require `log`; every producer sends it empty and carries the same tag. Optional here because
+ // protocol timeline items, where `log` is optional, flow into this type.
+ log?: string;
actions?: Array<{
index: number;
toolName: string;
diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts
index 8530e76844..8aa15581ae 100644
--- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts
+++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
- log: "[Bash] ls -1",
+ log: "",
},
});
});
test.each(["before", "after"])(
- "keeps one logged Task card when child tools start %s the parent finishes",
+ "keeps one Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
- log: "[Bash] ls -1",
+ log: "",
},
});
},
diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts
index 84293afa6a..f219baa1b1 100644
--- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts
+++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
queryFactory.mockReset();
});
- test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
+ test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
- expect(latest.detail.log).toContain("[Read] README.md");
- expect(latest.detail.log).toContain("[Edit] src/index.ts");
- expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
+ expect(latest.detail.log).toBe("");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
.map((event) => event.item)
.at(-1);
- expect(latestSubAgentUpdate?.detail).toMatchObject({
- type: "sub_agent",
- log: expect.stringContaining("[Read] README.md"),
- });
+ expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
});
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
- test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
+ test("keeps the Task card the same size while the child runs", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
- const timelineToolCalls = events
- .filter(
- (event): event is Extract =>
- event.type === "timeline" && event.item.type === "tool_call",
+ const runningCards = events
+ .flatMap((event) =>
+ event.type === "timeline" &&
+ event.item.type === "tool_call" &&
+ event.item.callId === "task-tail-1" &&
+ event.item.status === "running" &&
+ event.item.detail.type === "sub_agent"
+ ? [event.item]
+ : [],
)
- .map((event) => event.item)
- .filter((item) => item.callId === "task-tail-1");
- const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
- const latest = subAgentUpdates[subAgentUpdates.length - 1];
- expect(latest).toBeDefined();
- if (!latest || latest.detail.type !== "sub_agent") {
- throw new Error("expected sub_agent detail");
- }
+ .map((item) => JSON.stringify(item));
+ expect(runningCards).toHaveLength(1);
- expect(latest.detail.log).not.toContain("[Read] file-1.md");
- expect(latest.detail.log).not.toContain("[Read] file-5.md");
- expect(latest.detail.log).toContain("[Read] file-6.md");
- expect(latest.detail.log).toContain("[Read] file-205.md");
+ const childToolCalls = events.filter(
+ (event) =>
+ event.type === "provider_subagent" &&
+ event.event.type === "timeline" &&
+ event.event.id === "task-tail-1" &&
+ event.event.item.type === "tool_call",
+ );
+ expect(childToolCalls).toHaveLength(205);
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts
index 824a93f3da..c675b1ed62 100644
--- a/packages/server/src/server/agent/providers/claude/agent.ts
+++ b/packages/server/src/server/agent/providers/claude/agent.ts
@@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession {
/**
* The Task card in the parent transcript, built from the declaration.
*
- * The sidechain tracker also emits this card, enriched with the child's action log, and the two
+ * The sidechain tracker also emits this card when it first sees the child, and the two
* collapse on the shared tool-call id. Emitting it at declaration matters because a
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
@@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
+ // COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
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 a17c1cd58c..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
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
- it("retains a background child's actions across the parent finishing", () => {
+ it("keeps a background child's card across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
- tracker.handleMessage(action("ls", "ls -1"), "task-1");
+ const parentCards = (events: ReturnType) =>
+ events.filter((event) => event.type === "timeline");
+ const childCallIds = (events: ReturnType) =>
+ events.flatMap((event) =>
+ event.type === "provider_subagent" &&
+ event.event.type === "timeline" &&
+ event.event.item.type === "tool_call"
+ ? [event.event.item.callId]
+ : [],
+ );
+
+ expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
expect(tracker.finishAll("completed")).toEqual([]);
- const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
- expect(events).toContainEqual(
- expect.objectContaining({
- type: "timeline",
- item: expect.objectContaining({
- detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
- }),
- }),
- );
+ const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
+ expect(parentCards(afterFinish)).toEqual([]);
+ expect(childCallIds(afterFinish)).toEqual(["pwd"]);
+
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
- expect(next).toContainEqual(
- expect.objectContaining({
- type: "timeline",
- item: expect.objectContaining({
- detail: expect.objectContaining({ log: "[Bash] ls" }),
- }),
- }),
- );
+ expect(parentCards(next)).toHaveLength(1);
+ 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", () => {
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 083b82cea6..5ca79641f9 100644
--- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts
+++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts
@@ -5,7 +5,6 @@ import {
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
-import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
type ClaudeToolCallItem = Extract;
-interface SubAgentActionEntry {
- index: number;
+interface SubAgentChildCall {
toolName: string;
input: unknown;
- summary?: string;
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
- actions: SubAgentActionEntry[];
- actionKeys: string[];
- nextActionIndex: number;
- actionIndexByKey: Map;
+ /** The child's tool calls awaiting results, which carry no input of their own. */
+ runningCalls: Map;
completedActionKeys: Set;
}
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
input: unknown;
}
-const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function readTrimmedString(value: unknown): string | undefined {
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
- * after that answer, so its card keeps the settled status and output and gains the child's
- * action log.
+ * after that answer, so its card keeps the settled status and output.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
+ const existingState = this.activeSidechains.get(parentToolUseId);
const state =
- this.activeSidechains.get(parentToolUseId) ??
+ existingState ??
({
- actions: [],
- actionKeys: [],
- nextActionIndex: 1,
- actionIndexByKey: new Map(),
+ runningCalls: new Map(),
completedActionKeys: new Set(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
- const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
+ // The parent card is a launch marker: it changes only when the child is first seen or the
+ // task input names it differently, never with the child's activity.
+ const cardUpdated =
+ this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message, state),
...this.extractSubAgentToolResults(message, state),
];
- let actionUpdated = false;
for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
- if (this.appendSubAgentAction(state, action)) {
- actionUpdated = true;
+ if (this.trackChildCall(state, action)) {
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
}
}
- if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
+ if (!cardUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
- log: state.actions
- .map((action) =>
- action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
- )
- .join("\n"),
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
actions: [],
};
@@ -170,18 +159,19 @@ export class ClaudeSidechainTracker {
},
];
- const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
- ? [
- {
- type: "timeline",
- item: {
- ...toolCall,
- detail,
+ const parentCard: AgentStreamEvent[] =
+ cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
+ ? [
+ {
+ type: "timeline",
+ item: {
+ ...toolCall,
+ detail,
+ },
+ provider: "claude",
},
- provider: "claude",
- },
- ]
- : [];
+ ]
+ : [];
return [
...descriptorEvents,
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
const callId = readTrimmedString(block.tool_use_id);
if (!callId || state.completedActionKeys.has(callId)) continue;
- const actionIndex = state.actionIndexByKey.get(callId);
- const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
+ const action = state.runningCalls.get(callId);
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
+ state.runningCalls.delete(callId);
state.completedActionKeys.add(callId);
items.push(toolCall);
}
@@ -412,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[] {
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
return [];
}
- private appendSubAgentAction(
+ /** Returns whether the call is new or has gained its input, so its running card changes. */
+ private trackChildCall(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate,
): boolean {
- const normalizedToolName = readTrimmedString(candidate.toolName);
- if (!normalizedToolName) {
+ const toolName = readTrimmedString(candidate.toolName);
+ if (!toolName) {
return false;
}
-
- const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
- const existingIndex = state.actionIndexByKey.get(candidate.key);
-
- if (existingIndex !== undefined) {
- const existing = state.actions[existingIndex];
- if (!existing) {
- return false;
- }
- const nextSummary = existing.summary ?? summary;
- if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
- return false;
- }
- state.actions[existingIndex] = {
- ...existing,
- toolName: normalizedToolName,
- input: existing.input ?? candidate.input,
- ...(nextSummary ? { summary: nextSummary } : {}),
- };
- return true;
+ const existing = state.runningCalls.get(candidate.key);
+ if (existing && (existing.input !== null || candidate.input === null)) {
+ return false;
}
-
- state.actions.push({
- index: state.nextActionIndex,
- toolName: normalizedToolName,
- input: candidate.input,
- ...(summary ? { summary } : {}),
- });
- state.nextActionIndex += 1;
- state.actionKeys.push(candidate.key);
- this.trimSubAgentTail(state);
- this.rebuildSubAgentActionIndex(state);
+ state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
return true;
}
-
- private trimSubAgentTail(state: SubAgentActivityState): void {
- while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
- state.actions.shift();
- const removedKey = state.actionKeys.shift();
- if (removedKey) state.completedActionKeys.delete(removedKey);
- }
- }
-
- private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
- state.actionIndexByKey.clear();
- for (let index = 0; index < state.actionKeys.length; index += 1) {
- const key = state.actionKeys[index];
- if (key) {
- state.actionIndexByKey.set(key, index);
- }
- }
- }
-
- private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
- const runningToolCall = mapClaudeRunningToolCall({
- name: toolName,
- callId: `sub-agent-summary-${toolName}`,
- input,
- output: null,
- });
- if (!runningToolCall) {
- return undefined;
- }
- const display = buildToolCallDisplayModel({
- name: runningToolCall.name,
- status: runningToolCall.status,
- error: runningToolCall.error,
- detail: runningToolCall.detail,
- metadata: runningToolCall.metadata,
- });
- return this.normalizeSubAgentText(display.summary);
- }
}
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts
index 5a6592862c..f564b7bf94 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts
@@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response.
const completedChildIndex = events.findIndex(
(event) =>
- event.type === "timeline" &&
- event.item.type === "tool_call" &&
- event.item.status === "completed" &&
- event.item.detail.type === "sub_agent" &&
- event.item.detail.log.includes("CHILD_SENTINEL"),
+ event.type === "provider_subagent" &&
+ event.event.type === "upsert" &&
+ event.event.status === "completed",
);
+ const childAssistantText = events
+ .flatMap((event) =>
+ event.type === "provider_subagent" &&
+ event.event.type === "timeline" &&
+ event.event.item.type === "assistant_message"
+ ? [event.event.item.text]
+ : [],
+ )
+ .join("");
const rootMessageIndexes = events.flatMap((event, index) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
);
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
.map((item) => item.text)
.join("");
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
+ expect(childAssistantText).toContain("CHILD_SENTINEL");
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
expect(terminalIndexes).toHaveLength(1);
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 cb8cb2684b..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
@@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => {
]);
});
- test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
+ test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -3562,7 +3562,6 @@ describe("Codex app-server provider", () => {
});
const timelineEvents = events.filter((event) => event.type === "timeline");
- expect(timelineEvents).toHaveLength(4);
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
const finalItem = timelineEvents.at(-1)?.item;
expect(finalItem).toMatchObject({
@@ -3574,7 +3573,7 @@ describe("Codex app-server provider", () => {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Report findings.",
- log: "[Assistant] Found the path.",
+ log: "",
actions: [],
},
});
@@ -3651,13 +3650,9 @@ describe("Codex app-server provider", () => {
id: "settled-child-thread",
status: "completed",
});
- expect(events.at(-1)).toMatchObject({
+ expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
- item: {
- callId: "call-settled-child",
- status: "completed",
- detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
- },
+ item: { callId: "call-settled-child", status: "completed" },
});
appServer.completesSubAgentActivity({
@@ -3962,6 +3957,8 @@ describe("Codex app-server provider", () => {
createTestLogger(),
async () => appServer.child,
);
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
@@ -4005,11 +4002,18 @@ describe("Codex app-server provider", () => {
type: "tool_call",
callId: "spawn-child-early",
status: "completed",
- detail: {
- type: "sub_agent",
- log: "[Assistant] Early child findings.",
- },
+ detail: { type: "sub_agent", log: "" },
});
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "provider_subagent",
+ event: expect.objectContaining({
+ type: "timeline",
+ id: "child-thread-early",
+ item: expect.objectContaining({ type: "assistant_message" }),
+ }),
+ }),
+ );
appServer.assertNoErrors();
} finally {
await session.close();
@@ -4080,10 +4084,7 @@ describe("Codex app-server provider", () => {
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-stable",
status: "canceled",
- detail: {
- type: "sub_agent",
- log: "[Assistant] Still on the same card.",
- },
+ detail: { type: "sub_agent", log: "" },
});
appServer.assertNoErrors();
} finally {
@@ -4163,11 +4164,10 @@ describe("Codex app-server provider", () => {
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
- expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
+ expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-late-compaction",
status: "completed",
- detail: { type: "sub_agent", log: "[Compacted]" },
});
appServer.assertNoErrors();
} finally {
@@ -4175,7 +4175,7 @@ describe("Codex app-server provider", () => {
}
});
- test("projects legacy child tools into one stable sub-agent log", async () => {
+ test("routes legacy child tools to the child timeline", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
@@ -4183,6 +4183,8 @@ describe("Codex app-server provider", () => {
createTestLogger(),
async () => appServer.child,
);
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the implementation.");
@@ -4225,19 +4227,26 @@ describe("Codex app-server provider", () => {
expect(finalToolCall).toMatchObject({
callId: "spawn-legacy-tool-child",
status: "completed",
- detail: { type: "sub_agent" },
+ detail: { type: "sub_agent", log: "" },
});
- if (finalToolCall?.detail.type === "sub_agent") {
- expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
- expect(finalToolCall.detail.log).toContain("[Edit]");
- }
+ const childToolCalls = events.flatMap((event) =>
+ event.type === "provider_subagent" &&
+ event.event.type === "timeline" &&
+ event.event.id === "legacy-tool-child" &&
+ event.event.item.type === "tool_call"
+ ? [event.event.item]
+ : [],
+ );
+ expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
+ new Set(["shell", "edit"]),
+ );
appServer.assertNoErrors();
} finally {
await session.close();
}
});
- test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
+ test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4297,8 +4306,22 @@ describe("Codex app-server provider", () => {
expect(beforeParentCompletes.at(-1)).toMatchObject({
callId: "spawn-child-root",
status: "running",
- detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
+ detail: { type: "sub_agent", log: "" },
});
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "provider_subagent",
+ event: {
+ type: "timeline",
+ id: "grandchild-thread",
+ item: {
+ type: "assistant_message",
+ messageId: "grandchild-message",
+ text: "Grandchild findings.",
+ },
+ },
+ }),
+ );
await asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-root",
@@ -4365,13 +4388,20 @@ describe("Codex app-server provider", () => {
"child-thread-root",
"child-thread-root",
]);
- expect(events.at(-1)).toMatchObject({
- type: "timeline",
- item: {
- callId: "spawn-child-root",
- detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
- },
- });
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "provider_subagent",
+ event: {
+ type: "timeline",
+ id: "grandchild-thread",
+ item: {
+ type: "assistant_message",
+ messageId: "grandchild-after-wait",
+ text: "Still nested.",
+ },
+ },
+ }),
+ );
});
test("never treats an unmapped foreign terminal as the root terminal", async () => {
@@ -4791,7 +4821,7 @@ describe("Codex app-server provider", () => {
expect(events).toHaveLength(eventCountAfterSpawn);
});
- test("keeps child context compaction inside the child card", async () => {
+ test("keeps child context compaction on the child timeline", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4824,10 +4854,70 @@ describe("Codex app-server provider", () => {
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
),
).toBe(true);
- expect(timelineItems.at(-1)).toMatchObject({
- type: "tool_call",
- detail: { type: "sub_agent", log: "[Compacted]" },
+ expect(
+ events.flatMap((event) =>
+ event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
+ ),
+ ).toEqual([
+ {
+ type: "timeline",
+ id: "child-thread-compaction",
+ item: { type: "compaction", status: "loading" },
+ },
+ {
+ type: "timeline",
+ id: "child-thread-compaction",
+ item: { type: "compaction", status: "completed" },
+ },
+ ]);
+ });
+
+ 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 () => {
@@ -4862,7 +4952,7 @@ describe("Codex app-server provider", () => {
},
});
- expect(events.at(-1)?.item).toMatchObject({
+ expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
@@ -5157,7 +5247,7 @@ describe("Codex app-server provider", () => {
);
expect(liveToolCalls.at(-1)).toMatchObject({
status: "running",
- detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
+ detail: { type: "sub_agent", log: "" },
});
liveEvents.length = 0;
@@ -5170,12 +5260,12 @@ describe("Codex app-server provider", () => {
itemId: "legacy-child-message-after-resume",
delta: "Legacy findings after resume.",
});
- expect(liveEvents.at(-1)).toMatchObject({
+ expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
item: {
callId: "legacy-spawn-history",
status: "running",
- detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
+ detail: { type: "sub_agent", log: "" },
},
});
});
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 f7d4f50215..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
@@ -49,10 +49,10 @@ import { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
+import { isDeepStrictEqual } from "node:util";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
-import { curateAgentActivity } from "../activity-curator.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
mapCodexToolCallEnvelope,
@@ -3384,8 +3384,6 @@ interface CodexSubAgentCallState {
activityItemIds: Set;
pendingCommandOutputDeltas: Map;
pendingFileChangeOutputDeltas: Map;
- childItemOrder: string[];
- childItems: Map;
childThreadIds: Set;
}
@@ -3487,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;
@@ -5516,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
@@ -5692,20 +5696,10 @@ export class CodexAppServerAgentSession implements AgentSession {
activityItemIds: new Set(),
pendingCommandOutputDeltas: new Map(),
pendingFileChangeOutputDeltas: new Map(),
- childItemOrder: [],
- childItems: new Map(),
childThreadIds: new Set(),
} satisfies CodexSubAgentCallState);
- state.toolCall = {
- ...timelineItem,
- detail: {
- ...timelineItem.detail,
- log:
- timelineItem.detail.log ||
- (state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
- },
- };
+ state.toolCall = timelineItem;
state.parentCallId ??= parentCallId;
state.parentSubagentId ??= parentSubagentId;
const activity = readCodexSubAgentActivity(rawItem);
@@ -5758,25 +5752,17 @@ export class CodexAppServerAgentSession implements AgentSession {
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,
- },
- };
- }
+ const subAgentType =
+ activityToolCall?.detail.type === "sub_agent"
+ ? activityToolCall.detail.subAgentType
+ : undefined;
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
- this.emitSubAgentActivityUpdate(callId, nextStatus);
+ this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
return true;
}
@@ -5817,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;
}
@@ -5827,17 +5819,6 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
- private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
- const state = this.subAgentCallsByCallId.get(callId);
- if (!state) {
- return;
- }
- if (!state.childItems.has(itemId)) {
- state.childItemOrder.push(itemId);
- }
- state.childItems.set(itemId, item);
- }
-
private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null,
@@ -5847,7 +5828,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
- this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
const state = this.subAgentCallsByCallId.get(subAgentCallId);
if (state) {
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
@@ -5861,26 +5841,15 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
- private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
- return state.childItemOrder
- .map((itemId) => state.childItems.get(itemId))
- .filter((item): item is AgentTimelineItem => Boolean(item));
- }
-
private emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
- options?: { reopen?: boolean },
+ options?: { reopen?: boolean; subAgentType?: string },
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
return;
}
- const childTimeline = this.getSubAgentChildTimeline(state);
- const log =
- childTimeline.length > 0
- ? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
- : "";
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
@@ -5889,16 +5858,12 @@ export class CodexAppServerAgentSession implements AgentSession {
) {
resolvedStatus = state.toolCall.status;
}
- for (const childThreadId of state.childThreadIds) {
- this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
- }
- const baseToolCall = {
- ...state.toolCall,
- detail: {
- ...state.toolCall.detail,
- log,
- },
- };
+ const baseToolCall = options?.subAgentType
+ ? {
+ ...state.toolCall,
+ detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
+ }
+ : state.toolCall;
const nextToolCall: ToolCallTimelineItem =
resolvedStatus === "failed"
? {
@@ -5911,10 +5876,14 @@ export class CodexAppServerAgentSession implements AgentSession {
status: resolvedStatus,
error: null,
};
+ // The card is a launch marker: the child's activity streams to its own timeline, so the card
+ // is re-sent only when its status or label changes.
+ const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall);
state.toolCall = nextToolCall;
- if (state.parentCallId && state.parentCallId !== callId) {
- this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
- this.emitSubAgentActivityUpdate(state.parentCallId);
+ for (const childThreadId of state.childThreadIds) {
+ this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
+ }
+ if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
@@ -6022,7 +5991,6 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
if (itemId) {
- this.upsertSubAgentChildItem(callId, itemId, timelineItem);
this.pendingAgentMessages.delete(itemId);
this.pendingReasoning.delete(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
@@ -6033,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;
}
- if (item.id) {
- this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
+ 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,
@@ -6076,11 +6054,6 @@ export class CodexAppServerAgentSession implements AgentSession {
text: parsed.delta,
});
}
- this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
- type: "assistant_message",
- messageId: parsed.itemId,
- text,
- });
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
@@ -6114,10 +6087,6 @@ export class CodexAppServerAgentSession implements AgentSession {
text: parsed.delta,
});
}
- this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
- type: "reasoning",
- text: prev.join(""),
- });
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
@@ -6185,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";
@@ -6771,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;
}
@@ -6802,9 +6778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
- if (parsed.item.id) {
- this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
- }
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
@@ -6858,9 +6831,6 @@ export class CodexAppServerAgentSession implements AgentSession {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
- if (itemId) {
- this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
- }
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
return;
}
diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
index 64629bc5d9..4e77dc9367 100644
--- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
+++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
@@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem(
type: "sub_agent",
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
+ // COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem(
type: "sub_agent",
subAgentType: nativeName,
description,
+ // COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
},
diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts
index f2f405540b..c8b44c0bd8 100644
--- a/packages/server/src/server/agent/providers/omp/agent.ts
+++ b/packages/server/src/server/agent/providers/omp/agent.ts
@@ -69,7 +69,7 @@ import {
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
-import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
+import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
@@ -130,7 +130,6 @@ export interface OmpAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
runtime?: OmpRuntime;
- subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -205,7 +204,6 @@ interface OmpAgentSessionOptions {
initialState: OmpSessionState;
currentModeId?: string | null;
logger: Logger;
- subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession {
this.logger.debug({ err: error }, "OMP context usage poll failed");
},
});
- this.subagentCardTracker = new OmpSubagentCardTracker({
- scheduler: options.subagentCardScheduler,
- });
+ this.subagentCardTracker = new OmpSubagentCardTracker();
this.lastKnownThinkingOptionId =
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
@@ -2155,8 +2151,7 @@ export class OmpAgentSession implements AgentSession {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
- mapSubagentDetail: (detail) =>
- this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
+ mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
});
}
@@ -2246,7 +2241,6 @@ export class OmpAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
- private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler;
@@ -2265,7 +2259,6 @@ export class OmpAgentClient implements AgentClient {
);
this.logger = options.logger;
this.runtimeSettings = runtimeSettings;
- this.subagentCardScheduler = options.subagentCardScheduler;
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
@@ -2349,7 +2342,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
- subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -2421,7 +2413,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
- subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
index 03c0ebe5ae..6d39c21c2d 100644
--- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
- log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
+ log: "",
},
error: null,
},
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 5164097661..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
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
+import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
@@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
},
];
-class ManualScheduler implements OmpSubagentCardScheduler {
- private currentMs = 0;
- private nextId = 1;
- private readonly timers = new Map void }>();
-
- now(): number {
- return this.currentMs;
- }
-
- setTimeout(callback: () => void, delayMs: number) {
- const id = this.nextId;
- this.nextId += 1;
- this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
- return { token: id };
- }
-
- clearTimeout(timer: { token: unknown }): void {
- if (typeof timer.token === "number") {
- this.timers.delete(timer.token);
- }
- }
-
- advance(ms: number): void {
- this.currentMs += ms;
- const dueTimers = [...this.timers.entries()]
- .filter(([, timer]) => timer.dueMs <= this.currentMs)
- .sort((left, right) => left[1].dueMs - right[1].dueMs);
- for (const [id, timer] of dueTimers) {
- if (this.timers.delete(id)) {
- timer.callback();
- }
- }
- }
-}
-
describe("OmpSubagentCardTracker", () => {
- test("folds lifecycle and progress into one throttled sub-agent detail", () => {
- const scheduler = new ManualScheduler();
+ test("keeps the card a launch marker while the child runs", () => {
const emitted: string[] = [];
- const tracker = new OmpSubagentCardTracker({
- scheduler,
- emitToolCall: (toolCallId) => {
- emitted.push(toolCallId);
- return true;
- },
- });
- tracker.handleLifecycle(LIFECYCLE);
+ const emit = (toolCallId: string) => {
+ emitted.push(toolCallId);
+ return true;
+ };
+ const tracker = new OmpSubagentCardTracker();
+ tracker.handleLifecycle(LIFECYCLE, emit);
for (const payload of PROGRESS) {
- tracker.handleProgress(payload);
+ tracker.handleProgress(payload, emit);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
- const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
- type: "sub_agent",
- subAgentType: "task",
- description: "Task arg description wins",
- log: "",
- });
- expect(detailBeforeTrailing).toEqual({
- type: "sub_agent",
- subAgentType: "task",
- description: "Task arg description wins",
- childSessionId: SESSION_FILE,
- log: [
- "EchoSubagent started",
- "[bash] echo subagent-hi",
- "[yield]",
- "EchoSubagent completed",
- ].join("\n"),
- });
-
- scheduler.advance(500);
-
- expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
- });
-
- test("aggregates batch progress streams into one index-prefixed log", () => {
- const scheduler = new ManualScheduler();
- const emitted: string[] = [];
- const tracker = new OmpSubagentCardTracker({
- scheduler,
- emitToolCall: (toolCallId) => {
- emitted.push(toolCallId);
- return true;
- },
- });
-
- tracker.handleLifecycle({
- id: "Explore",
- agent: "task",
- description: "Inspect files",
- status: "started",
- sessionFile: "/tmp/one.jsonl",
- parentToolCallId: "task.batch-1",
- index: 0,
- });
- scheduler.advance(600);
- tracker.handleProgress({
- index: 5,
- agent: "task",
- task: "Run tests",
- parentToolCallId: "task.batch-1",
- progress: {
- id: "Test",
- status: "running",
- description: "Run tests",
- recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
- },
- sessionFile: "/tmp/two.jsonl",
- });
-
- expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
- tracker.detailFor("task.batch-1", {
+ tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
- subAgentType: "batch",
- actions: [],
+ subAgentType: "task",
+ description: "Task arg description wins",
log: "",
}),
).toEqual({
type: "sub_agent",
- subAgentType: "batch",
- description: "Inspect files",
- childSessionId: "/tmp/one.jsonl",
- log: "[1/6] Explore started\n[6/6] [bash] npm test",
- actions: [],
+ subAgentType: "task",
+ description: "Task arg description wins",
+ childSessionId: SESSION_FILE,
+ log: "",
});
});
- test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
- const scheduler = new ManualScheduler();
+ test("re-emits the card when a later event names the child", () => {
const emitted: string[] = [];
- const tracker = new OmpSubagentCardTracker({
- scheduler,
- emitToolCall: (toolCallId) => {
- emitted.push(toolCallId);
- return true;
+ const emit = (toolCallId: string) => {
+ emitted.push(toolCallId);
+ return true;
+ };
+ const tracker = new OmpSubagentCardTracker();
+ tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
+ tracker.handleProgress(PROGRESS[0], emit);
+
+ expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
+ expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
+ type: "sub_agent",
+ childSessionId: SESSION_FILE,
+ log: "",
+ });
+
+ tracker.delete(PARENT_TOOL_CALL_ID);
+ expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
+ type: "sub_agent",
+ 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: "",
});
-
- tracker.handleLifecycle({
- id: "Explore",
- agent: "task",
- description: "Inspect files",
- status: "started",
- parentToolCallId: "task-1",
- index: 0,
- });
- scheduler.advance(100);
- tracker.handleProgress({
- index: 0,
- agent: "task",
- task: "Inspect files",
- parentToolCallId: "task-1",
- progress: {
- id: "Explore",
- status: "running",
- recentOutput: ["found target file"],
- },
- });
-
- expect(emitted).toEqual(["task-1"]);
- expect(
- tracker.detailFor("task-1", {
- type: "sub_agent",
- log: "",
- }).log,
- ).toBe("Explore started\nfound target file");
-
- tracker.delete("task-1");
- scheduler.advance(500);
-
- expect(emitted).toEqual(["task-1"]);
- expect(
- tracker.detailFor("task-1", {
- type: "sub_agent",
- log: "static",
- }).log,
- ).toBe("static");
});
});
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 7034c4c506..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
@@ -1,361 +1,106 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
-export interface OmpSubagentCardTimer {
- readonly token: unknown;
-}
-
-export interface OmpSubagentCardScheduler {
- now(): number;
- setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
- clearTimeout(timer: OmpSubagentCardTimer): void;
-}
-
-export interface OmpSubagentCardTrackerOptions {
- scheduler?: OmpSubagentCardScheduler;
- emitToolCall?: (toolCallId: string) => boolean;
-}
-
-interface OmpSubagentLogLine {
- key: string;
- text: string;
-}
-
-interface OmpSubagentCardItem {
+interface OmpSubagentCardChild {
index: number;
description?: string;
- agent?: string;
- status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
- lines: OmpSubagentLogLine[];
- lineKeys: Set;
-}
-
-interface OmpSubagentCardState {
- items: Map;
- emitToolCall: (toolCallId: string) => boolean;
- lastEmitMs: number | null;
- trailingTimer: OmpSubagentCardTimer | null;
- dirty: boolean;
-}
-
-const MAX_LOG_LINES_PER_ITEM = 200;
-const MAX_LOG_LINE_CHARS = 240;
-const THROTTLE_INTERVAL_MS = 500;
-
-export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
- return {
- now: () => Date.now(),
- setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
- clearTimeout: (timer) => {
- clearTimeout(timer.token as ReturnType);
- },
- };
}
+/**
+ * Fills a task card's description and child session from OMP's sub-agent events. The card is a
+ * launch marker that names the task's first child; the children's activity belongs to their own
+ * timelines.
+ */
export class OmpSubagentCardTracker {
- private readonly scheduler: OmpSubagentCardScheduler;
- private readonly emitToolCall: (toolCallId: string) => boolean;
- private readonly states = new Map();
-
- constructor(options: OmpSubagentCardTrackerOptions) {
- this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
- this.emitToolCall = options.emitToolCall ?? (() => false);
- }
+ private readonly firstChildren = new Map();
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
- emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
+ emitToolCall: (toolCallId: string) => boolean,
): void {
- const parentToolCallId = readTrimmedString(payload.parentToolCallId);
- if (!parentToolCallId) {
- return;
- }
- this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
- const item = this.upsertItem(parentToolCallId, {
- index: payload.index,
- agent: payload.agent,
- description: payload.description,
- status: payload.status === "started" ? "running" : payload.status,
- childSessionId: payload.sessionFile,
- });
- this.appendLine(
- item,
- `lifecycle:${payload.status}:${payload.id}`,
- `${payload.id} ${payload.status}`,
+ this.update(
+ payload.parentToolCallId,
+ {
+ index: payload.index,
+ description: payload.description,
+ childSessionId: payload.sessionFile,
+ },
+ emitToolCall,
);
- this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
- emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
+ emitToolCall: (toolCallId: string) => boolean,
): void {
- const parentToolCallId = readTrimmedString(payload.parentToolCallId);
- if (!parentToolCallId) {
- return;
- }
- this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
- const item = this.upsertItem(parentToolCallId, {
- index: payload.index,
- agent: payload.agent,
- description: payload.progress.description,
- status: payload.progress.status,
- childSessionId: payload.sessionFile,
- });
-
- const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
- if (currentToolLine) {
- this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
- }
-
- for (const tool of readRecordArray(payload.progress.recentTools)) {
- const line = summarizeTool(tool);
- if (line) {
- this.appendLine(item, `tool:${line.key}`, line.text);
- }
- }
-
- for (const output of readOutputLines(payload.progress.recentOutput)) {
- this.appendLine(item, `output:${output}`, output);
- }
-
- if (payload.progress.status !== "running") {
- this.appendLine(
- item,
- `status:${payload.progress.status}:${payload.progress.id}`,
- `${payload.progress.id} ${payload.progress.status}`,
- );
- }
-
- this.requestEmit(parentToolCallId);
+ this.update(
+ payload.parentToolCallId,
+ {
+ index: payload.index,
+ description: payload.progress.description,
+ childSessionId: payload.sessionFile,
+ },
+ emitToolCall,
+ );
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
- const state = this.states.get(toolCallId);
- if (!state) {
+ const first = this.firstChildren.get(toolCallId);
+ if (!first) {
return baseDetail;
}
- const items = [...state.items.values()].sort((left, right) => left.index - right.index);
- const firstItem = items[0];
- if (!firstItem) {
- return baseDetail;
- }
-
- const detail: ToolCallDetail = {
- type: "sub_agent",
- ...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
- ...((baseDetail.description ?? firstItem.description)
- ? { description: baseDetail.description ?? firstItem.description }
- : {}),
- ...((firstItem.childSessionId ?? baseDetail.childSessionId)
- ? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
- : {}),
- log: buildLog(items, baseDetail.log),
+ const description = baseDetail.description ?? first.description;
+ const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
+ return {
+ ...baseDetail,
+ ...(description ? { description } : {}),
+ ...(childSessionId ? { childSessionId } : {}),
};
- return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
- }
-
- flush(toolCallId: string): void {
- const state = this.states.get(toolCallId);
- if (!state || !state.dirty) {
- return;
- }
- this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
- const state = this.states.get(toolCallId);
- if (state?.trailingTimer) {
- this.scheduler.clearTimeout(state.trailingTimer);
- }
- this.states.delete(toolCallId);
+ this.firstChildren.delete(toolCallId);
}
clear(): void {
- for (const toolCallId of this.states.keys()) {
- this.delete(toolCallId);
- }
+ this.firstChildren.clear();
}
- private upsertItem(
- parentToolCallId: string,
- input: {
- index: number;
- agent?: string;
- status?: "pending" | "running" | "completed" | "failed" | "aborted";
- description?: string;
- childSessionId?: string;
- },
- ): OmpSubagentCardItem {
- const state = this.stateFor(parentToolCallId);
- const existing = state.items.get(input.index);
- if (existing) {
- existing.agent = readTrimmedString(input.agent) ?? existing.agent;
- existing.status = input.status ?? existing.status;
- existing.description = readTrimmedString(input.description) ?? existing.description;
- existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
- return existing;
+ private update(
+ parentToolCallId: string | undefined,
+ input: { index: number; description?: string; childSessionId?: string },
+ emitToolCall: (toolCallId: string) => boolean,
+ ): void {
+ const toolCallId = readTrimmedString(parentToolCallId);
+ if (!toolCallId) {
+ return;
}
-
- const item: OmpSubagentCardItem = {
+ 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,
- lines: [],
- lineKeys: new Set(),
- ...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
- ...(input.status ? { status: input.status } : {}),
- ...(readTrimmedString(input.description)
- ? { description: readTrimmedString(input.description) }
- : {}),
- ...(readTrimmedString(input.childSessionId)
- ? { childSessionId: readTrimmedString(input.childSessionId) }
- : {}),
+ description: readTrimmedString(input.description) ?? existing?.description,
+ childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
};
- state.items.set(input.index, item);
- return item;
- }
-
- private stateFor(parentToolCallId: string): OmpSubagentCardState {
- const existing = this.states.get(parentToolCallId);
- if (existing) {
- return existing;
- }
- const state: OmpSubagentCardState = {
- items: new Map(),
- emitToolCall: this.emitToolCall,
- lastEmitMs: null,
- trailingTimer: null,
- dirty: false,
- };
- this.states.set(parentToolCallId, state);
- return state;
- }
-
- private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
- const normalizedText = normalizeLine(text);
- if (!normalizedText || item.lineKeys.has(key)) {
+ if (
+ existing &&
+ existing.description === next.description &&
+ existing.childSessionId === next.childSessionId
+ ) {
return;
}
- item.lines.push({ key, text: normalizedText });
- item.lineKeys.add(key);
- while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
- const removed = item.lines.shift();
- if (removed) {
- item.lineKeys.delete(removed.key);
- }
- }
+ this.firstChildren.set(toolCallId, next);
+ emitToolCall(toolCallId);
}
-
- private requestEmit(toolCallId: string): void {
- const state = this.stateFor(toolCallId);
- const now = this.scheduler.now();
- const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
- if (elapsedMs >= THROTTLE_INTERVAL_MS) {
- this.emitNow(toolCallId, state);
- return;
- }
-
- state.dirty = true;
- if (state.trailingTimer) {
- return;
- }
- state.trailingTimer = this.scheduler.setTimeout(() => {
- state.trailingTimer = null;
- if (state.dirty) {
- this.emitNow(toolCallId, state);
- }
- }, THROTTLE_INTERVAL_MS - elapsedMs);
- }
-
- private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
- if (state.trailingTimer) {
- this.scheduler.clearTimeout(state.trailingTimer);
- state.trailingTimer = null;
- }
- state.dirty = false;
- state.lastEmitMs = this.scheduler.now();
- state.emitToolCall(toolCallId);
- }
-}
-
-function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
- const itemCount = Math.max(...items.map((item) => item.index)) + 1;
- const lines = items.flatMap((item) => {
- const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
- return item.lines.map((line) => `${prefix}${line.text}`);
- });
- return lines.length > 0 ? lines.join("\n") : fallback;
-}
-
-function summarizeTool(tool: Record | null): OmpSubagentLogLine | null {
- if (!tool) {
- return null;
- }
- const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
- if (!toolName) {
- return null;
- }
- const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
- const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
- return {
- key: `${toolName}:${args ?? ""}${endMs}`,
- text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
- };
-}
-
-function readOutputLines(value: unknown): string[] {
- if (!Array.isArray(value)) {
- return [];
- }
- return value.flatMap((item) => {
- const text =
- readTrimmedString(item) ??
- (isRecord(item)
- ? (readTrimmedString(item.text) ??
- readTrimmedString(item.output) ??
- readTrimmedString(item.content))
- : undefined);
- return text ? [text] : [];
- });
-}
-
-function readRecord(value: unknown): Record | null {
- return isRecord(value) ? value : null;
-}
-
-function readRecordArray(value: unknown): Record[] {
- return Array.isArray(value) ? value.filter(isRecord) : [];
-}
-
-function summarizeUnknown(value: unknown): string | undefined {
- if (value === undefined || value === null) {
- return undefined;
- }
- if (typeof value === "string") {
- return readTrimmedString(value);
- }
- const json = JSON.stringify(value);
- return typeof json === "string" ? normalizeLine(json) : undefined;
-}
-
-function normalizeLine(value: string): string | undefined {
- const normalized = value.trim().replace(/\s+/g, " ");
- if (!normalized) {
- return undefined;
- }
- if (normalized.length <= MAX_LOG_LINE_CHARS) {
- return normalized;
- }
- return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
index ae4c0d0d77..cc13f9001f 100644
--- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
- test("uses task result text and transcript path as the best static replay detail", () => {
+ test("takes the child session from the task transcript path", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
- log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
+ log: "",
});
});
diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
index 86b1ee53ef..500123758a 100644
--- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
+++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
@@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
- const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
@@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
- log: resultText?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
}
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index 81288f3e4f..6fef290d91 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession {
...item,
status: "failed",
error,
- detail:
- item.detail.type === "sub_agent"
- ? {
- ...item.detail,
- log: [item.detail.log, error.message]
- .filter((entry) => entry.trim().length > 0)
- .join("\n"),
- }
- : item.detail,
},
},
turnId,
diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
index 8214ebb1d0..b3eb432f86 100644
--- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
+++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts
@@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z
])
.nullable();
-function formatLogEntry(value: unknown): string | undefined {
- const outputText = readOutputText(value);
- if (outputText) {
- return outputText;
- }
- if (value === null || value === undefined) {
- return undefined;
- }
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
-}
-
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
const text = readOutputText(value);
if (text) {
@@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
function deriveOpencodeTaskDetail(
input: unknown,
output: unknown,
- error: unknown,
metadata: Record | undefined,
): ToolCallDetail | null {
if (!isRecord(input)) {
@@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail(
return null;
}
- const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
const childSessionId =
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
return {
@@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail(
...(subAgentType ? { subAgentType } : {}),
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
- log,
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
actions: [],
};
}
@@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown,
- error: unknown = null,
metadata?: Record,
): ToolCallDetail {
if (toolName.trim().toLowerCase() === "task") {
- const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
+ const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
if (taskDetail) {
return taskDetail;
}
diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts
index 1c9ba212e1..eb38a04ac6 100644
--- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
});
});
- it("maps completed task calls with final output log to sub_agent detail", () => {
+ it("maps completed task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
- log: "Found the CLI entrypoint and provider registry.",
+ log: "",
actions: [],
});
});
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
subAgentType: "explore",
description: "Explore current directory",
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
- log: 'done',
+ log: "",
actions: [],
});
});
- it("maps aborted task calls with preserved error log to sub_agent detail", () => {
+ it("maps aborted task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
- log: "Tool execution aborted",
+ log: "",
actions: [],
});
});
diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts
index 8fbc861a84..261a4ec827 100644
--- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts
+++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
const error = raw.error ?? null;
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
const status = normalizeToolCallStatus(rawStatus, error, output);
- const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
+ const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata);
if (status === "failed") {
return {
diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts
index c00e39aaad..c58f0faf15 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts
@@ -1,5 +1,4 @@
import { z } from "zod";
-import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiChildSessionFile, PiExtension } from "../contract.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
@@ -99,7 +98,8 @@ export const edxethPiSubagents: PiExtension = {
detail: {
type: "sub_agent",
description,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
},
subagents,
childSessions,
diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts
index f89a9aefd3..7bb62a9d1f 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts
@@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => {
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "completed"]);
+ expect(
+ events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
+ ).toEqual([
+ {
+ type: "timeline",
+ id: "call_502323",
+ item: {
+ type: "assistant_message",
+ text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.",
+ },
+ },
+ ]);
});
test("correlates a background notification and result collection", async () => {
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts
index e1869705f8..9383911c3d 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts
@@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: title,
description,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
if (call.status === "running")
return {
@@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = {
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
pendingFilesByAgent.delete(details.data.agentId);
const childSessions = childSession(call.callId, file);
+ const childStatus = status(details.data.status);
+ // Without a session file, as on replay, the child's answer is the tool result.
+ const answer =
+ !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
return {
detail,
subagents: [
@@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = {
title,
description,
toolCallId: call.callId,
- status: status(details.data.status),
+ status: childStatus,
},
+ ...(answer
+ ? [
+ {
+ type: "timeline" as const,
+ id: call.callId,
+ item: { type: "assistant_message" as const, text: answer },
+ },
+ ]
+ : []),
],
childSessions,
};
@@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = {
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
if (call.status === "running" || !details.success) return { detail };
const file = details.data.transcriptPath;
diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts
index 0bf32a9f2f..d374c1a02d 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
type: "sub_agent",
subAgentType: "scout",
description: "Inspect files",
- log: "Found two files",
+ log: "",
},
}),
);
diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
index 8e9db54047..740d66aadc 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
@@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => {
type: "sub_agent",
subAgentType: "reviewer",
description: "Review the Pi mapper change",
- log: "The mapper change preserves provider status.",
+ log: "",
});
});
diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts
index 64e814af3f..058171e8df 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts
@@ -131,7 +131,8 @@ export const piSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: args.data.agent,
description: args.data.task,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
const base = {
type: "upsert" as const,
diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts
index 8a9ca61d08..96ba771594 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts
@@ -54,8 +54,6 @@ export async function verifySubagentFixture(
try {
await session.startTurn("Delegate work");
for (const event of fixture.events) pi.latestSession().emit(event);
- const immediate = live.filter((event) => event.type === "provider_subagent");
- expect(immediate.some((event) => event.event.type === "timeline")).toBe(false);
pi.latestSession().finishTurn();
const replay: AgentStreamEvent[] = [];
for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event);
diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts
index ad666bb4d3..60f3b12869 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts
@@ -36,12 +36,17 @@ describe("@tintinweb/pi-subagents adapter", () => {
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "completed" }),
+ {
+ type: "timeline",
+ id: "call-1",
+ item: { type: "assistant_message", text: "Wrapped up (turn limit)" },
+ },
]);
expect(mapping?.detail).toEqual({
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect",
- log: "Wrapped up (turn limit)",
+ log: "",
});
});
test("maps captured foreground lifecycle live and on replay", async () => {
@@ -53,6 +58,18 @@ describe("@tintinweb/pi-subagents adapter", () => {
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "completed"]);
+ expect(
+ events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
+ ).toEqual([
+ {
+ type: "timeline",
+ id: "call_458926",
+ item: {
+ type: "assistant_message",
+ text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).",
+ },
+ },
+ ]);
});
test("uses a structured notification to complete a background child", async () => {
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
@@ -147,7 +164,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
expect(mapping?.detail).toEqual({
type: "sub_agent",
description: undefined,
- log: "Wrapped up (turn limit)",
+ log: "",
});
},
);
diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts
index 56b5935172..6858dec8be 100644
--- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts
+++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts
@@ -57,7 +57,8 @@ export const tintinwebPiSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: title,
description,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
if (call.status === "running")
return {
@@ -84,6 +85,10 @@ export const tintinwebPiSubagents: PiExtension = {
const file = outputFileFromToolResult(call.result);
const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : [];
if (file) readSessions.add(file);
+ const childStatus = status(details.data.status);
+ // A foreground child has no session file; its answer is the tool result.
+ const answer =
+ !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
return {
detail,
subagents: [
@@ -93,8 +98,17 @@ export const tintinwebPiSubagents: PiExtension = {
title,
description,
toolCallId: call.callId,
- status: status(details.data.status),
+ status: childStatus,
},
+ ...(answer
+ ? [
+ {
+ type: "timeline" as const,
+ id: call.callId,
+ item: { type: "assistant_message" as const, text: answer },
+ },
+ ]
+ : []),
],
childSessions,
};
@@ -110,7 +124,8 @@ export const tintinwebPiSubagents: PiExtension = {
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
- log: extractTextFromToolResult(call.result)?.trim() ?? "",
+ // COMPAT(subagent-log): remove after 2027-04-30.
+ log: "",
};
if (call.status === "running" || !details.success) return { detail };
return {
diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts
index 7eba32f22a..557f8360de 100644
--- a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts
@@ -1,6 +1,3 @@
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
-import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -10,7 +7,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
-test("background cards retain child actions after the parent finishes (#6253)", async () => {
+test("background children keep their actions after the parent finishes (#6253)", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
@@ -62,22 +59,6 @@ test("background cards retain child actions after the parent finishes (#6253)",
limit: 0,
projection: "projected",
});
- const { stdout } = await promisify(execFile)(
- process.execPath,
- [
- "--conditions=source",
- "--import",
- "tsx",
- fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
- "--host",
- `127.0.0.1:${daemon.port}`,
- "logs",
- agent.id,
- ],
- { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
- );
- expect(stdout).toContain("[Bash] ls -1");
- expect(stdout).toContain("[Bash] pwd");
expect(
atFinish.entries.filter(
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
@@ -94,9 +75,6 @@ test("background cards retain child actions after the parent finishes (#6253)",
),
).toBe(true);
}
- for (const card of cards) {
- expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
- }
} finally {
await client.close();
await daemon.close();