Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ec97c8982b fix(codex): keep control cards from claiming child routes
Only spawn announcements establish child ownership. Wait and close results update existing children, while failed resume attempts retain their own visible result without replacing a previous child outcome.
2026-10-30 06:45:18 +00:00
paseo-bot[bot] fca6e24fc0 fix(codex): restore outcomes after child follow-ups
Replay must distinguish an earlier terminal outcome from the next outcome after an accepted follow-up. Keep that distinction with each projected child, and normalize wait and close results consistently for original spawn cards and standalone control cards.
2026-10-30 06:27:32 +00:00
paseo-bot[bot] 9615afb837 fix(codex): preserve follow-up instructions in parent history
Follow-up submissions carry user-visible instructions and must keep their own cards. Their sampled child states describe submission time, so only the submission result settles the card while child lifecycle events continue to own child status.
2026-10-30 06:16:48 +00:00
paseo-bot[bot] a98fa27901 test(server): collect fixture garbage before Git filter measurement 2026-10-30 06:03:39 +00:00
paseo-bot[bot] 5e7c7e609e fix(codex): leave follow-up status to child lifecycle events 2026-10-30 06:03:39 +00:00
paseo-bot[bot] 8207f697c5 fix(codex): preserve final errors and settle rendered wait cards 2026-10-30 06:03:39 +00:00
paseo-bot[bot] a02fa39125 fix(codex): settle closed children independently of prior errors
Codex derives a close record's status from the child's previous state,
so closing an errored child produces a failed record even after shutdown.
Treat close as a terminal-preserving child event rather than a successful
control-card status, retaining earlier completion when a close is stale.
2026-10-30 06:03:39 +00:00
paseo-bot[bot] 6041e86dad fix(codex): fold live control starts into existing subagents
A wait starts without child states. Recognize it from its receivers so
live handling consumes both lifecycle notifications, as replay does,
instead of leaving an extra running control card behind. Normalize
child status directly with the mapper's existing child-status rule.
2026-10-30 06:03:39 +00:00
cbfb29c5fa fix(codex): settle children closed before they finish
A successful close records the child's previous running state. Replaying
that snapshot must stop counting the child as working. Exercise replay
and live control updates through the session interface instead of calling
private notification methods.

Co-authored-by: geeknonerd <14756651+geeknonerd@users.noreply.github.com>
Co-authored-by: altenli <liqian115@gmail.com>
2026-10-30 06:03:39 +00:00
popstk dc23fe6545 fix(codex): reconcile nullable collaboration results with spawned agents 2026-10-30 06:03:39 +00:00
63 changed files with 1796 additions and 664 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "default",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
-35
View File
@@ -59,38 +59,3 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -23,7 +22,14 @@ import {
type ComponentType,
type RefObject,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Modal, Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
@@ -64,7 +64,7 @@ function resolveShouldFill(
): boolean {
if (!fillAvailableHeight) return false;
const t = detail?.type;
return t === "shell" || t === "edit" || t === "write" || t === "read";
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
}
function useDetailStyles(
@@ -186,6 +186,184 @@ 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 (
<View style={styles.subAgentActionRow}>
<Text selectable style={styles.subAgentActionTool}>
{formatSubAgentToolName(action.toolName)}
</Text>
{action.summary ? (
<Text selectable style={styles.subAgentActionSummary}>
{action.summary}
</Text>
) : null}
</View>
);
}
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 (
<Text selectable style={styles.scrollText}>
{activityLog}
</Text>
);
}
if (!hasActions) {
return (
<Text selectable style={styles.scrollText}>
{fallbackHeader}
</Text>
);
}
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 (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
</Text>
) : null}
{hasActions ? (
<View style={styles.subAgentActions}>
{actions.map((action) => (
<SubAgentActionRow key={action.index} action={action} />
))}
</View>
) : null}
<SubAgentLogText
activityLog={remainingLog}
fallbackHeader={fallbackHeader}
hasActions={hasActions}
/>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
interface EditDetailProps {
diffLines: DiffLine[] | undefined;
ds: DetailStyles;
@@ -458,7 +636,19 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
key="sub-agent"
log={detail.log}
childSessionId={detail.childSessionId}
subAgentType={detail.subAgentType}
description={detail.description}
ds={ds}
/>,
];
}
if (detail.type === "edit") {
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
}
@@ -710,6 +900,34 @@ 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,
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import {
View,
Text,
Pressable,
Modal,
ScrollView,
Platform,
StatusBar,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
View,
-13
View File
@@ -1,13 +0,0 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
export function Modal(props: ModalProps) {
return <NativeModal supportedOrientations={orientations} {...props} />;
}
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
Children,
cloneElement,
@@ -17,6 +16,7 @@ import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
View,
type PressableProps,
+8 -2
View File
@@ -1,7 +1,13 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1
View File
@@ -832,6 +832,7 @@ 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");
});
+1
View File
@@ -2013,6 +2013,7 @@ export const ar: TranslationResources = {
toolCallDetails: {
error: "خطأ",
empty: "لا توجد تفاصيل إضافية متاحة",
subAgentActivity: "نشاط الوكيل الفرعي",
input: "مدخل",
output: "الإخراج",
},
+1
View File
@@ -2038,6 +2038,7 @@ export const en = {
toolCallDetails: {
error: "Error",
empty: "No additional details available",
subAgentActivity: "Sub-agent activity",
input: "Input",
output: "Output",
},
+1
View File
@@ -2061,6 +2061,7 @@ export const es: TranslationResources = {
toolCallDetails: {
error: "Error",
empty: "No hay detalles adicionales disponibles",
subAgentActivity: "Actividad de subagente",
input: "Aporte",
output: "Producción",
},
+1
View File
@@ -2060,6 +2060,7 @@ export const fr: TranslationResources = {
toolCallDetails: {
error: "Erreur",
empty: "Aucun détail supplémentaire disponible",
subAgentActivity: "Activité du sous-agent",
input: "Entrée",
output: "Sortie",
},
+1
View File
@@ -2033,6 +2033,7 @@ export const ja: TranslationResources = {
toolCallDetails: {
error: "エラー",
empty: "追加の詳細はありません",
subAgentActivity: "サブエージェントアクティビティ",
input: "入力",
output: "出力",
},
+1
View File
@@ -2023,6 +2023,7 @@ export const ko: TranslationResources = {
toolCallDetails: {
error: "오류",
empty: "추가 세부 정보가 없습니다",
subAgentActivity: "서브에이전트 활동",
input: "입력",
output: "출력",
},
+1
View File
@@ -2046,6 +2046,7 @@ export const ptBR: TranslationResources = {
toolCallDetails: {
error: "Erro",
empty: "Nenhum detalhe adicional disponível",
subAgentActivity: "Atividade do subagente",
input: "Entrada",
output: "Saída",
},
+1
View File
@@ -2046,6 +2046,7 @@ export const ru: TranslationResources = {
toolCallDetails: {
error: "Ошибка",
empty: "Дополнительные сведения отсутствуют",
subAgentActivity: "Активность субагента",
input: "Входные данные",
output: "Выходные данные",
},
+1
View File
@@ -1991,6 +1991,7 @@ export const zhCN: TranslationResources = {
toolCallDetails: {
error: "错误",
empty: "没有可用的更多详情",
subAgentActivity: "Sub-agent 活动",
input: "输入",
output: "输出",
},
@@ -40,6 +40,12 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
return Boolean(detail.url || detail.result || detail.codeText);
}
function hasMeaningfulSubAgentDetail(
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
): boolean {
return Boolean(detail.subAgentType || detail.description || detail.log);
}
function hasMeaningfulPlainTextDetail(
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
): boolean {
@@ -69,9 +75,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
case "sub_agent":
return false;
case "sub_agent":
return hasMeaningfulSubAgentDetail(detail);
case "plain_text":
return hasMeaningfulPlainTextDetail(detail);
case "plan":
@@ -86,7 +92,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
if (params.detail?.type === "worktree_setup") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
+1 -1
View File
@@ -288,7 +288,7 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
log?: string;
log: string;
actions?: Array<{
index: number;
toolName: string;
+1 -3
View File
@@ -638,9 +638,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
subAgentType: z.string().optional(),
description: z.string().optional(),
childSessionId: z.string().optional(),
// 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(),
log: z.string(),
// 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.
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
});
});
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
const card = {
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call",
callId: "call-sub-agent-1",
name: "Task",
@@ -212,26 +212,12 @@ describe("wire schema compatibility", () => {
subAgentType: "Explore",
description: "Inspect repository structure",
childSessionId: "child-session-1",
log: "",
log: "[Read] README.md",
actions: [],
},
} as const;
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" } });
expect(parsed.detail.actions).toEqual([]);
});
test("old clients parse agent snapshots with rewind capabilities", () => {
@@ -247,7 +247,7 @@ second line'`,
]);
});
it("lists each sub-agent as its launch marker", () => {
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
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: "",
log: "[Assistant] First child result.",
},
}),
{ type: "assistant_message", text: "Between children." },
@@ -268,7 +268,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
log: "",
log: "[Assistant] Second child result.",
},
}),
{ type: "assistant_message", text: "After second child." },
@@ -279,8 +279,10 @@ 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.",
]);
});
@@ -164,6 +164,9 @@ 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":
@@ -313,10 +313,7 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: 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;
log: string;
actions?: Array<{
index: number;
toolName: string;
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "",
log: "[Bash] ls -1",
},
});
});
test.each(["before", "after"])(
"keeps one Task card when child tools start %s the parent finishes",
"keeps one logged 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: "",
log: "[Bash] ls -1",
},
});
},
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
queryFactory.mockReset();
});
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
@@ -307,7 +307,9 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
expect(latest.detail.log).toBe("");
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");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
@@ -394,7 +396,10 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
.map((event) => event.item)
.at(-1);
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
expect(latestSubAgentUpdate?.detail).toMatchObject({
type: "sub_agent",
log: expect.stringContaining("[Read] README.md"),
});
});
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
@@ -548,7 +553,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
test("keeps the Task card the same size while the child runs", async () => {
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({
@@ -563,27 +568,24 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
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]
: [],
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call",
)
.map((item) => JSON.stringify(item));
expect(runningCards).toHaveLength(1);
.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");
}
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);
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");
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
@@ -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 when it first sees the child, and the two
* The sidechain tracker also emits this card, enriched with the child's action log, 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,7 +6276,6 @@ 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: [],
};
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("keeps a background child's card across the parent finishing", () => {
it("retains a background child's actions across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
@@ -15,67 +15,27 @@ describe("ClaudeSidechainTracker", () => {
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
events.filter((event) => event.type === "timeline");
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
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);
tracker.handleMessage(action("ls", "ls -1"), "task-1");
expect(tracker.finishAll("completed")).toEqual([]);
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(parentCards(afterFinish)).toEqual([]);
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
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" }),
}),
}),
);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
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" },
});
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
});
it("uses Claude's native agent name for the provider subagent title", () => {
@@ -5,6 +5,7 @@ import {
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
@@ -19,17 +20,21 @@ interface ClaudeContentChunk {
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentChildCall {
interface SubAgentActionEntry {
index: number;
toolName: string;
input: unknown;
summary?: string;
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
/** The child's tool calls awaiting results, which carry no input of their own. */
runningCalls: Map<string, SubAgentChildCall>;
actions: SubAgentActionEntry[];
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
completedActionKeys: Set<string>;
}
@@ -39,6 +44,7 @@ interface SubAgentActionCandidate {
input: unknown;
}
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function readTrimmedString(value: unknown): string | undefined {
@@ -73,7 +79,8 @@ 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.
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
@@ -84,27 +91,28 @@ export class ClaudeSidechainTracker {
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
const existingState = this.activeSidechains.get(parentToolUseId);
const state =
existingState ??
this.activeSidechains.get(parentToolUseId) ??
({
runningCalls: new Map<string, SubAgentChildCall>(),
actions: [],
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
// 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 contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
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.trackChildCall(state, action)) {
if (this.appendSubAgentAction(state, action)) {
actionUpdated = true;
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
@@ -117,7 +125,7 @@ export class ClaudeSidechainTracker {
}
}
if (!cardUpdated && childTimelineItems.length === 0) {
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -137,8 +145,11 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
actions: [],
};
@@ -159,19 +170,18 @@ export class ClaudeSidechainTracker {
},
];
const parentCard: AgentStreamEvent[] =
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
},
provider: "claude",
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
},
]
: [];
provider: "claude",
},
]
: [];
return [
...descriptorEvents,
@@ -293,7 +303,8 @@ 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 action = state.runningCalls.get(callId);
const actionIndex = state.actionIndexByKey.get(callId);
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
@@ -306,7 +317,6 @@ export class ClaudeSidechainTracker {
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
state.runningCalls.delete(callId);
state.completedActionKeys.add(callId);
items.push(toolCall);
}
@@ -402,12 +412,13 @@ export class ClaudeSidechainTracker {
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
// 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 }];
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
@@ -437,20 +448,84 @@ export class ClaudeSidechainTracker {
return [];
}
/** Returns whether the call is new or has gained its input, so its running card changes. */
private trackChildCall(
private appendSubAgentAction(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate,
): boolean {
const toolName = readTrimmedString(candidate.toolName);
if (!toolName) {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
return false;
}
const existing = state.runningCalls.get(candidate.key);
if (existing && (existing.input !== null || candidate.input === null)) {
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;
}
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
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);
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);
}
}
@@ -77,19 +77,12 @@ CHILD_SENTINEL in your own response.
const completedChildIndex = events.findIndex(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.status === "completed",
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"),
);
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] : [],
);
@@ -112,7 +105,6 @@ 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);
@@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => {
]);
});
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -3562,6 +3562,7 @@ 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({
@@ -3573,7 +3574,7 @@ describe("Codex app-server provider", () => {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Report findings.",
log: "",
log: "[Assistant] Found the path.",
actions: [],
},
});
@@ -3650,9 +3651,13 @@ describe("Codex app-server provider", () => {
id: "settled-child-thread",
status: "completed",
});
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: { callId: "call-settled-child", status: "completed" },
item: {
callId: "call-settled-child",
status: "completed",
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
},
});
appServer.completesSubAgentActivity({
@@ -3957,8 +3962,6 @@ 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.");
@@ -4002,18 +4005,11 @@ describe("Codex app-server provider", () => {
type: "tool_call",
callId: "spawn-child-early",
status: "completed",
detail: { type: "sub_agent", log: "" },
detail: {
type: "sub_agent",
log: "[Assistant] Early child findings.",
},
});
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();
@@ -4084,7 +4080,10 @@ describe("Codex app-server provider", () => {
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-stable",
status: "canceled",
detail: { type: "sub_agent", log: "" },
detail: {
type: "sub_agent",
log: "[Assistant] Still on the same card.",
},
});
appServer.assertNoErrors();
} finally {
@@ -4164,10 +4163,11 @@ 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"]);
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "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("routes legacy child tools to the child timeline", async () => {
test("projects legacy child tools into one stable sub-agent log", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
@@ -4183,8 +4183,6 @@ 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.");
@@ -4227,26 +4225,19 @@ describe("Codex app-server provider", () => {
expect(finalToolCall).toMatchObject({
callId: "spawn-legacy-tool-child",
status: "completed",
detail: { type: "sub_agent", log: "" },
detail: { type: "sub_agent" },
});
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"]),
);
if (finalToolCall?.detail.type === "sub_agent") {
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
expect(finalToolCall.detail.log).toContain("[Edit]");
}
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4306,22 +4297,8 @@ describe("Codex app-server provider", () => {
expect(beforeParentCompletes.at(-1)).toMatchObject({
callId: "spawn-child-root",
status: "running",
detail: { type: "sub_agent", log: "" },
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
});
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",
@@ -4388,20 +4365,13 @@ describe("Codex app-server provider", () => {
"child-thread-root",
"child-thread-root",
]);
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.",
},
},
}),
);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "spawn-child-root",
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
},
});
});
test("never treats an unmapped foreign terminal as the root terminal", async () => {
@@ -4821,7 +4791,7 @@ describe("Codex app-server provider", () => {
expect(events).toHaveLength(eventCountAfterSpawn);
});
test("keeps child context compaction on the child timeline", async () => {
test("keeps child context compaction inside the child card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4854,70 +4824,10 @@ describe("Codex app-server provider", () => {
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
),
).toBe(true);
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",
expect(timelineItems.at(-1)).toMatchObject({
type: "tool_call",
detail: { type: "sub_agent", log: "[Compacted]" },
});
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 () => {
@@ -4952,7 +4862,7 @@ describe("Codex app-server provider", () => {
},
});
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
@@ -5247,7 +5157,7 @@ describe("Codex app-server provider", () => {
);
expect(liveToolCalls.at(-1)).toMatchObject({
status: "running",
detail: { type: "sub_agent", log: "" },
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
});
liveEvents.length = 0;
@@ -5260,12 +5170,12 @@ describe("Codex app-server provider", () => {
itemId: "legacy-child-message-after-resume",
delta: "Legacy findings after resume.",
});
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
expect(liveEvents.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "legacy-spawn-history",
status: "running",
detail: { type: "sub_agent", log: "" },
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
},
});
});
@@ -49,15 +49,16 @@ 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,
mapCodexToolCallFromThreadItem,
normalizeCommandExecutionCommand,
normalizeCollabAgentChildStatus,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import {
@@ -1857,6 +1858,77 @@ function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null
};
}
interface CodexCollabAgentUpdate {
threadId: string;
status: ToolCallTimelineItem["status"];
preservesTerminal: boolean;
}
type CodexCollabAgentControl =
| { kind: "submission"; childThreadIds: string[] }
| { kind: "outcome"; childThreadIds: string[]; updates: CodexCollabAgentUpdate[] };
interface HistoricalCodexSubAgent {
timelineIndex: number;
pendingFollowUp: boolean;
}
function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | null {
const record = toObjectRecord(item);
if (
!record ||
normalizeCodexThreadItemType(typeof record.type === "string" ? record.type : undefined) !==
"collabAgentToolCall" ||
record.tool === "spawnAgent"
) {
return null;
}
const tool = typeof record.tool === "string" ? record.tool : undefined;
const receivers = Array.isArray(record.receiverThreadIds)
? record.receiverThreadIds.filter((id): id is string => typeof id === "string")
: [];
if (tool === "sendInput") {
return record.status === "completed" && record.error == null
? { kind: "submission", childThreadIds: receivers }
: null;
}
if (tool !== "wait" && tool !== "closeAgent") return null;
const outcomeStates = toObjectRecord(record.agentsStates);
const updates = Object.entries(outcomeStates ?? {}).flatMap(
([threadId, value]): CodexCollabAgentUpdate[] => {
const state = toObjectRecord(value);
if (typeof state?.status !== "string") return [];
// Unlike a spawn snapshot, a control result can report the child's final error.
const status = normalizeCollabAgentChildStatus(state.status, tool);
return [
{
threadId,
status,
preservesTerminal: state.status === "shutdown" || record.tool === "closeAgent",
},
];
},
);
return {
kind: "outcome",
childThreadIds: [...new Set([...receivers, ...updates.map((update) => update.threadId)])],
updates,
};
}
function resolveCodexCollabAgentUpdate(
previous: ToolCallTimelineItem["status"],
update: CodexCollabAgentUpdate,
): ToolCallTimelineItem["status"] {
if (
isTerminalSubAgentStatus(previous) &&
(update.preservesTerminal || update.status === "running")
) {
return previous;
}
return update.status;
}
function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean {
return source === "codex_event" && !readCodexSubAgentActivity(item);
}
@@ -1897,7 +1969,7 @@ function updateHistoricalSubAgentActivity(
};
}
function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] {
function readCodexSpawnedSubAgentThreadIds(item: unknown): string[] {
const activity = readCodexSubAgentActivity(item);
if (activity) {
return activity.kind === "started" ? [activity.agentThreadId] : [];
@@ -2060,23 +2132,57 @@ async function threadItemToTimelineEntries(
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
function updateHistoricalCollabAgentCalls(
timeline: PersistedTimelineEntry[],
subAgentsByThreadId: Map<string, HistoricalCodexSubAgent>,
item: unknown,
): boolean {
const control = readCodexCollabAgentControl(item);
if (!control) return false;
if (control.kind === "submission") {
for (const threadId of control.childThreadIds) {
const child = subAgentsByThreadId.get(threadId);
if (child) child.pendingFollowUp = true;
}
return false;
}
for (const update of control.updates) {
const child = subAgentsByThreadId.get(update.threadId);
const existing = child === undefined ? undefined : timeline[child.timelineIndex];
if (!child || existing?.item.type !== "tool_call") continue;
const status = child.pendingFollowUp
? update.status
: resolveCodexCollabAgentUpdate(existing.item.status, update);
child.pendingFollowUp = false;
const toolCall: ToolCallTimelineItem =
status === "failed"
? { ...existing.item, status, error: { message: "Sub-agent failed" } }
: { ...existing.item, status, error: null };
timeline[child.timelineIndex] = { ...existing, item: toolCall };
}
return (
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => subAgentsByThreadId.has(id))
);
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
}): Promise<CodexThreadHistoryProjection> {
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
const subAgentsByThreadId = new Map<string, HistoricalCodexSubAgent>();
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
await projectCodexThreadHistory(params, { timeline, subAgentsByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
const item = timeline[timelineIndex]?.item;
const subAgentRoutes = Array.from(subAgentsByThreadId.entries()).flatMap(
([childThreadId, child]): PersistedSubAgentRoute[] => {
const item = timeline[child.timelineIndex]?.item;
return item?.type === "tool_call" && item.detail.type === "sub_agent"
? [{ childThreadId, toolCall: item }]
: [];
@@ -2089,17 +2195,20 @@ async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
subAgentsByThreadId: Map<string, HistoricalCodexSubAgent>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
const { timeline, subAgentsByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
if (updateHistoricalCollabAgentCalls(timeline, subAgentsByThreadId, item)) {
continue;
}
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
const existingIndex = subAgentsByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
)?.timelineIndex;
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
@@ -2129,8 +2238,11 @@ async function projectCodexThreadHistory(
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
for (const childThreadId of readCodexSpawnedSubAgentThreadIds(item)) {
subAgentsByThreadId.set(childThreadId, {
timelineIndex: timeline.length - 1,
pendingFollowUp: false,
});
}
}
}
@@ -3384,6 +3496,8 @@ interface CodexSubAgentCallState {
activityItemIds: Set<string>;
pendingCommandOutputDeltas: Map<string, string[]>;
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
@@ -3485,9 +3599,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
private pendingRootCompactionItemIds = new Set<string>();
private pendingAnonymousRootCompactions = 0;
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
// `thread/compacted` or the end of the child's turn.
private pendingChildCompactionThreadIds = new Set<string>();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
@@ -4027,6 +4138,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerSubAgentToolCall({
timelineItem: next.route.toolCall,
rawItem: { agentThreadId: next.route.childThreadId },
childThreadIds: [next.route.childThreadId],
parentCallId: next.parentCallId,
parentSubagentId: next.parentSubagentId,
});
@@ -5517,9 +5629,6 @@ 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
@@ -5677,6 +5786,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private registerSubAgentToolCall(params: {
timelineItem: ToolCallTimelineItem;
rawItem: { [key: string]: unknown };
childThreadIds: readonly string[];
parentCallId: string | null;
parentSubagentId: string | null;
}): string[] {
@@ -5696,10 +5806,20 @@ export class CodexAppServerAgentSession implements AgentSession {
activityItemIds: new Set<string>(),
pendingCommandOutputDeltas: new Map<string, string[]>(),
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState);
state.toolCall = timelineItem;
state.toolCall = {
...timelineItem,
detail: {
...timelineItem.detail,
log:
timelineItem.detail.log ||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
},
};
state.parentCallId ??= parentCallId;
state.parentSubagentId ??= parentSubagentId;
const activity = readCodexSubAgentActivity(rawItem);
@@ -5708,16 +5828,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.subAgentCallsByCallId.set(timelineItem.callId, state);
const receiverThreadIds = Array.isArray(rawItem.receiverThreadIds)
? rawItem.receiverThreadIds.filter((value): value is string => typeof value === "string")
: [];
const agentThreadId =
typeof rawItem.agentThreadId === "string" && rawItem.agentThreadId.length > 0
? rawItem.agentThreadId
: null;
const childThreadIds = Array.from(
new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds),
).filter((threadId) => threadId !== this.currentThreadId);
const childThreadIds = Array.from(new Set(params.childThreadIds)).filter(
(threadId) => threadId !== this.currentThreadId,
);
for (const receiverThreadId of childThreadIds) {
const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId);
if (owningCallId && owningCallId !== timelineItem.callId) {
@@ -5730,7 +5843,36 @@ export class CodexAppServerAgentSession implements AgentSession {
return childThreadIds;
}
private handleRegisteredCollabAgentControl(
rawItem: { [key: string]: unknown },
control: CodexCollabAgentControl,
): boolean {
if (control.kind === "submission") return false;
for (const update of control.updates) {
const callId = this.subAgentCallIdByChildThreadId.get(update.threadId);
if (!callId) continue;
const state = this.subAgentCallsByCallId.get(callId);
if (!state) continue;
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
if (status !== state.toolCall.status) {
this.emitSubAgentActivityUpdate(callId, status);
}
}
// A control rendered at start must also render its completion, even if
// Codex's final result lists fewer receivers than its started record.
const hasOwnCard = typeof rawItem.id === "string" && this.subAgentCallsByCallId.has(rawItem.id);
return (
!hasOwnCard &&
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id))
);
}
private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean {
const control = readCodexCollabAgentControl(rawItem);
if (control) {
return this.handleRegisteredCollabAgentControl(rawItem, control);
}
const activity = readCodexSubAgentActivity(rawItem);
if (!activity) {
return false;
@@ -5752,17 +5894,25 @@ export class CodexAppServerAgentSession implements AgentSession {
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
cwd: this.config.cwd ?? null,
});
const subAgentType =
activityToolCall?.detail.type === "sub_agent"
? activityToolCall.detail.subAgentType
: undefined;
if (
activityToolCall?.detail.type === "sub_agent" &&
state.toolCall.detail.type === "sub_agent"
) {
state.toolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
subAgentType: activityToolCall.detail.subAgentType,
},
};
}
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
this.emitSubAgentActivityUpdate(callId, nextStatus);
return true;
}
@@ -5803,13 +5953,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): boolean {
if (
childSubAgentCallId &&
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"completed",
)
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
) {
return true;
}
@@ -5819,6 +5963,17 @@ 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,
@@ -5828,6 +5983,7 @@ 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;
@@ -5841,15 +5997,26 @@ 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; subAgentType?: string },
options?: { reopen?: boolean },
): 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" &&
@@ -5858,12 +6025,16 @@ export class CodexAppServerAgentSession implements AgentSession {
) {
resolvedStatus = state.toolCall.status;
}
const baseToolCall = options?.subAgentType
? {
...state.toolCall,
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
}
: state.toolCall;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
const baseToolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
log,
},
};
const nextToolCall: ToolCallTimelineItem =
resolvedStatus === "failed"
? {
@@ -5876,14 +6047,10 @@ 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;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
if (state.parentCallId && state.parentCallId !== callId) {
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
this.emitSubAgentActivityUpdate(state.parentCallId);
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
@@ -5991,6 +6158,7 @@ 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);
@@ -6001,29 +6169,19 @@ 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 (status === "loading") {
this.pendingChildCompactionThreadIds.add(childThreadId);
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
} else {
this.settleChildCompaction(childThreadId);
if (item.id) {
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
}
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,
@@ -6054,6 +6212,11 @@ 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;
}
@@ -6087,6 +6250,10 @@ export class CodexAppServerAgentSession implements AgentSession {
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "reasoning",
text: prev.join(""),
});
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
@@ -6154,7 +6321,6 @@ 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";
@@ -6600,6 +6766,7 @@ export class CodexAppServerAgentSession implements AgentSession {
? this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
@@ -6741,13 +6908,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (
childSubAgentCallId &&
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"loading",
)
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
) {
return;
}
@@ -6773,11 +6934,15 @@ export class CodexAppServerAgentSession implements AgentSession {
const registeredChildThreadIds = this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item),
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
});
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;
@@ -6831,6 +6996,9 @@ 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;
}
@@ -0,0 +1,463 @@
import { describe, expect, test, vi } from "vitest";
import { CodexAppServerAgentSession } from "../codex-app-server-agent.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createFakeCodexAppServer } from "./test-utils/fake-app-server.js";
function collab(tool: string, states: Record<string, string>, id = tool) {
return {
type: "collabAgentToolCall",
id,
tool,
status: "completed",
prompt: tool === "spawnAgent" ? "Reply with a short result." : null,
receiverThreadIds: Object.keys(states),
agentsStates: Object.fromEntries(
Object.entries(states).map(([threadId, status]) => [threadId, { status }]),
),
};
}
async function replay(items: unknown[]) {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => ({
thread: {
turns: [
{
items: (params as { threadId: string }).threadId === "parent" ? items : [],
},
],
},
}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
{ sessionId: "parent" },
createTestLogger(),
async () => appServer.child,
{},
false,
false,
false,
undefined,
"history",
);
try {
await session.connect();
return await Array.fromAsync(session.streamHistory());
} finally {
await session.close();
appServer.assertNoErrors();
}
}
describe("Codex collaboration history", () => {
test("restores completed children from nullable wait and close results", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
collab("wait", { first: "completed" }, "wait-first"),
collab("wait", { second: "completed" }, "wait-second"),
collab("closeAgent", { first: "completed" }, "close-first"),
collab("closeAgent", { second: "completed" }, "close-second"),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: "completed" },
{ id: "second", status: "completed" },
]);
});
test.each(["completed", "interrupted", "running"])(
"keeps follow-up instructions in replay without applying the %s submission snapshot",
async (submissionStatus) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
{ ...collab("sendInput", { first: submissionStatus }), prompt: "Now add tests" },
]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status, detail: event.item.detail }]
: [],
),
).toEqual([
{ id: "spawn-first", status: "completed", detail: expect.anything() },
{
id: "sendInput",
status: "completed",
detail: expect.objectContaining({ description: "Now add tests" }),
},
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "completed" }]);
},
);
test.each([
["completed", "closeAgent", "errored", "failed"],
["errored", "closeAgent", "completed", "completed"],
["completed", "wait", "running", "running"],
["completed", "closeAgent", "running", "canceled"],
])(
"replaces the earlier %s outcome after a follow-up and %s result %s",
async (initialStatus, tool, resultStatus, expected) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
{
...collab("wait", { first: initialStatus, second: "completed" }),
status: initialStatus === "errored" ? "failed" : "completed",
},
{ ...collab("sendInput", { first: "running" }), prompt: "Do more work" },
{
...collab(tool, { first: resultStatus }),
status: resultStatus === "errored" ? "failed" : "completed",
},
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: expected },
{ id: "second", status: "completed" },
]);
expect(
history.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "spawn-first"
? [event.item.status]
: [],
),
).toEqual([expected]);
},
);
test("keeps the prior outcome when a follow-up submission fails", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
{ ...collab("sendInput", { first: "running" }), status: "failed", prompt: "Do more work" },
collab("wait", { first: "running" }, "stale-wait"),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
),
).toEqual(["completed"]);
});
test("settles a close card without a registered spawn", async () => {
const history = await replay([collab("closeAgent", { external: "running" })]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status }]
: [],
),
).toEqual([{ id: "closeAgent", status: "canceled" }]);
});
test.each(["notFound", "errored"])(
"preserves completion and shows a failed resume reporting %s",
async (status) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
collab("closeAgent", { first: "completed" }),
{ ...collab("resumeAgent", { first: status }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
),
).toEqual(["completed"]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status }]
: [],
),
).toEqual([
{ id: "spawn-first", status: "completed" },
{ id: "resumeAgent", status: "failed" },
]);
},
);
test("stops counting a child closed while running", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("closeAgent", { first: "running" }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "canceled" }]);
});
test.each(["wait", "closeAgent"])(
"restores an errored child as failed after %s",
async (tool) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
{ ...collab(tool, { first: "errored" }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "failed" }]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status, error: event.item.error }]
: [],
),
).toEqual([{ id: "spawn-first", status: "failed", error: { message: "Sub-agent failed" } }]);
},
);
test.each(["wait", "closeAgent"])(
"settles a live errored child as failed after %s",
async (tool) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const statuses = new Map<string, string>();
const cards = new Map<string, { status: string; error: unknown }>();
session.subscribe((event) => {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
if (event.type === "timeline" && event.item.type === "tool_call") {
cards.set(event.item.callId, { status: event.item.status, error: event.item.error });
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab(tool, { first: "errored" }),
status: "inProgress",
agentsStates: {},
});
notify("item/completed", { ...collab(tool, { first: "errored" }), status: "failed" });
appServer.completeTurn();
await result;
expect([...statuses]).toEqual([["first", "failed"]]);
expect([...cards]).toEqual([
["spawn-first", { status: "failed", error: { message: "Sub-agent failed" } }],
]);
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
test("preserves completion when a close carries an older running snapshot", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("wait", { first: "completed" }),
collab("closeAgent", { first: "running" }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "completed" }]);
});
test.each([
["shutdown", "canceled"],
["notFound", "failed"],
])("restores a %s child as %s and preserves its running sibling", async (status, expected) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
collab("closeAgent", { first: status }),
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([
{ id: "first", status: expected },
{ id: "second", status: "running" },
]);
});
test("settles a rendered live wait when its completion lists only the finished receiver", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const cards = new Map<string, string>();
const statuses = new Map<string, string>();
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "tool_call") {
cards.set(event.item.callId, event.item.status);
}
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Wait for the children.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab("wait", { first: "running", second: "running" }, "mixed-wait"),
status: "inProgress",
agentsStates: {},
});
await vi.waitFor(() => expect(cards.get("mixed-wait")).toBe("running"));
notify("item/completed", collab("wait", { first: "completed" }, "mixed-wait"));
notify("item/completed", collab("wait", { second: "completed" }, "wait-second"));
appServer.completeTurn();
await result;
expect([...statuses]).toEqual([["first", "completed"]]);
expect([...cards]).toEqual([
["spawn-first", "completed"],
["mixed-wait", "completed"],
["wait-second", "completed"],
]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test.each(["completed", "interrupted", "running"])(
"keeps a live follow-up running despite a late %s sendInput snapshot",
async (submissionStatus) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const statuses = new Map<string, string>();
const timelineCards = new Map<string, { status: string; detail: unknown }>();
let parentMessage = "";
session.subscribe((event) => {
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineCards.set(event.item.callId, {
status: event.item.status,
detail: event.item.detail,
});
}
if (event.type === "timeline" && event.item.type === "assistant_message") {
parentMessage = event.item.text;
}
});
function complete(tool: string, states: Record<string, string>, id = tool) {
const item = {
...collab(tool, states, id),
...(tool === "sendInput" ? { prompt: "Now add tests" } : {}),
};
if (tool !== "spawnAgent") {
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: "thread-1",
item: { ...item, status: "inProgress", agentsStates: {} },
},
})}\n`,
);
}
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", item },
})}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
complete("spawnAgent", { first: "pendingInit" }, "spawn-first");
complete("spawnAgent", { second: "pendingInit" }, "spawn-second");
complete("wait", { first: "completed", second: "running" });
await vi.waitFor(() => {
expect(statuses.get("first")).toBe("completed");
expect(statuses.get("second")).toBe("running");
});
complete("closeAgent", { first: "shutdown" });
complete("wait", { first: "running" }, "stale-wait");
appServer.says({ threadId: "thread-1", text: "Still waiting." });
appServer.completeTurn();
await result;
expect(statuses.get("first")).toBe("completed");
appServer.startsTurn({ threadId: "first", turnId: "follow-up" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("running"));
complete("sendInput", { first: submissionStatus });
appServer.says({ threadId: "thread-1", text: "Follow-up submitted." });
await vi.waitFor(() => expect(parentMessage).toBe("Follow-up submitted."));
expect(statuses.get("first")).toBe("running");
expect([...timelineCards.keys()]).toEqual(["spawn-first", "spawn-second", "sendInput"]);
expect(timelineCards.get("sendInput")).toEqual({
status: "completed",
detail: expect.objectContaining({ description: "Now add tests" }),
});
// Only the child's completion settles the follow-up.
appServer.completeTurn({ threadId: "first" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("completed"));
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
});
@@ -213,6 +213,20 @@ describe("codex tool-call mapper", () => {
});
});
it("accepts a nullable prompt in completed collaboration control items", () => {
expect(
mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "wait-child",
tool: "wait",
prompt: null,
status: "completed",
receiverThreadIds: ["child"],
agentsStates: { child: { status: "completed" } },
}),
).toMatchObject({ callId: "wait-child", status: "completed" });
});
it("maps collabAgentToolCall into canonical sub-agent detail", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
@@ -186,7 +186,7 @@ const CodexCollabAgentToolCallItemSchema = z
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
prompt: z.string().optional(),
prompt: z.string().nullish(),
tool: z.string().optional(),
receiverThreadIds: z.array(z.string()).optional(),
agentsStates: z.record(z.string(), z.unknown()).optional(),
@@ -545,12 +545,18 @@ function readStatus(value: unknown): string | undefined {
return typeof value.status === "string" ? value.status : undefined;
}
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
export function normalizeCollabAgentChildStatus(
status: string,
tool?: string,
): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
switch (normalized) {
case "error":
case "errored":
return "running";
return tool === "wait" || tool === "closeAgent" ? "failed" : "running";
case "running":
case "pendinginit":
return tool === "closeAgent" ? "canceled" : "running";
case "shutdown":
return "canceled";
case "notfound":
@@ -572,10 +578,15 @@ function resolveCollabAgentStatus(
return "failed";
}
// Sending input completes the submission, not the child's new turn.
if (item.tool === "sendInput") {
return parentStatus;
}
const childStatuses = Object.values(item.agentsStates ?? {})
.map(readStatus)
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
.map(normalizeCollabAgentChildStatus);
.map((status) => normalizeCollabAgentChildStatus(status, item.tool));
if (childStatuses.some((status) => status === "failed")) {
return "failed";
@@ -950,7 +961,6 @@ function mapCollabAgentToolCallItem(
type: "sub_agent",
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -1007,7 +1017,6 @@ function mapSubAgentActivityItem(
type: "sub_agent",
subAgentType: nativeName,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
},
@@ -69,7 +69,7 @@ import {
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } 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,6 +130,7 @@ export interface OmpAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
runtime?: OmpRuntime;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -204,6 +205,7 @@ interface OmpAgentSessionOptions {
initialState: OmpSessionState;
currentModeId?: string | null;
logger: Logger;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -771,7 +773,9 @@ export class OmpAgentSession implements AgentSession {
this.logger.debug({ err: error }, "OMP context usage poll failed");
},
});
this.subagentCardTracker = new OmpSubagentCardTracker();
this.subagentCardTracker = new OmpSubagentCardTracker({
scheduler: options.subagentCardScheduler,
});
this.lastKnownThinkingOptionId =
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
@@ -2151,7 +2155,8 @@ export class OmpAgentSession implements AgentSession {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
mapSubagentDetail: (detail) =>
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
});
}
@@ -2241,6 +2246,7 @@ 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;
@@ -2259,6 +2265,7 @@ 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;
@@ -2342,6 +2349,7 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -2413,6 +2421,7 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
},
error: null,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
@@ -59,84 +59,184 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => 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("keeps the card a launch marker while the child runs", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
for (const payload of PROGRESS) {
tracker.handleProgress(payload, emit);
tracker.handleProgress(payload);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
expect(
tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
}),
).toEqual({
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: "",
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
});
test("re-emits the card when a later event names the child", () => {
const emitted: string[] = [];
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);
scheduler.advance(500);
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: "",
});
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.delete(PARENT_TOOL_CALL_ID);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
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", {
type: "sub_agent",
subAgentType: "batch",
actions: [],
log: "",
}),
).toEqual({
type: "sub_agent",
log: "",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
});
});
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: "",
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
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",
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");
});
});
@@ -1,106 +1,361 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
interface OmpSubagentCardChild {
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 {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
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<typeof setTimeout>);
},
};
}
/**
* 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 firstChildren = new Map<string, OmpSubagentCardChild>();
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
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.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.progress.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
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);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const first = this.firstChildren.get(toolCallId);
if (!first) {
const state = this.states.get(toolCallId);
if (!state) {
return baseDetail;
}
const description = baseDetail.description ?? first.description;
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
return {
...baseDetail,
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
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),
};
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 {
this.firstChildren.delete(toolCallId);
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
}
clear(): void {
this.firstChildren.clear();
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
}
private update(
parentToolCallId: string | undefined,
input: { index: number; description?: string; childSessionId?: string },
emitToolCall: (toolCallId: string) => boolean,
): void {
const toolCallId = readTrimmedString(parentToolCallId);
if (!toolCallId) {
return;
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;
}
const first = this.firstChildren.get(toolCallId);
if (first && input.index > first.index) {
return;
}
const existing = first?.index === input.index ? first : undefined;
const next: OmpSubagentCardChild = {
const item: OmpSubagentCardItem = {
index: input.index,
description: readTrimmedString(input.description) ?? existing?.description,
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
lines: [],
lineKeys: new Set<string>(),
...(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) }
: {}),
};
if (
existing &&
existing.description === next.description &&
existing.childSessionId === next.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)) {
return;
}
this.firstChildren.set(toolCallId, next);
emitToolCall(toolCallId);
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);
}
}
}
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<string, unknown> | 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<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("takes the child session from the task transcript path", () => {
test("uses task result text and transcript path as the best static replay detail", () => {
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: "",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
});
});
@@ -179,6 +179,7 @@ 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",
@@ -199,8 +200,7 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: resultText?.trim() ?? "",
};
}
@@ -4846,6 +4846,15 @@ 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,
@@ -153,6 +153,21 @@ 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) {
@@ -180,6 +195,7 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
function deriveOpencodeTaskDetail(
input: unknown,
output: unknown,
error: unknown,
metadata: Record<string, unknown> | undefined,
): ToolCallDetail | null {
if (!isRecord(input)) {
@@ -192,6 +208,7 @@ function deriveOpencodeTaskDetail(
return null;
}
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
const childSessionId =
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
return {
@@ -199,8 +216,7 @@ function deriveOpencodeTaskDetail(
...(subAgentType ? { subAgentType } : {}),
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log,
actions: [],
};
}
@@ -357,10 +373,11 @@ export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown,
error: unknown = null,
metadata?: Record<string, unknown>,
): ToolCallDetail {
if (toolName.trim().toLowerCase() === "task") {
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
if (taskDetail) {
return taskDetail;
}
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
});
});
it("maps completed task calls to sub_agent detail", () => {
it("maps completed task calls with final output log 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: "",
log: "Found the CLI entrypoint and provider registry.",
actions: [],
});
});
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
subAgentType: "explore",
description: "Explore current directory",
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
log: "",
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
actions: [],
});
});
it("maps aborted task calls to sub_agent detail", () => {
it("maps aborted task calls with preserved error log 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: "",
log: "Tool execution aborted",
actions: [],
});
});
@@ -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, raw.metadata);
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
if (status === "failed") {
return {
@@ -1,4 +1,5 @@
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";
@@ -98,8 +99,7 @@ export const edxethPiSubagents: PiExtension = {
detail: {
type: "sub_agent",
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
},
subagents,
childSessions,
@@ -130,18 +130,6 @@ 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));
@@ -64,8 +64,7 @@ export const gotgenesPiSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: title,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running")
return {
@@ -93,10 +92,6 @@ 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: [
@@ -106,17 +101,8 @@ export const gotgenesPiSubagents: PiExtension = {
title,
description,
toolCallId: call.callId,
status: childStatus,
status: status(details.data.status),
},
...(answer
? [
{
type: "timeline" as const,
id: call.callId,
item: { type: "assistant_message" as const, text: answer },
},
]
: []),
],
childSessions,
};
@@ -132,8 +118,7 @@ export const gotgenesPiSubagents: PiExtension = {
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running" || !details.success) return { detail };
const file = details.data.transcriptPath;
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
type: "sub_agent",
subAgentType: "scout",
description: "Inspect files",
log: "",
log: "Found two files",
},
}),
);
@@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => {
type: "sub_agent",
subAgentType: "reviewer",
description: "Review the Pi mapper change",
log: "",
log: "The mapper change preserves provider status.",
});
});
@@ -131,8 +131,7 @@ export const piSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: args.data.agent,
description: args.data.task,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
const base = {
type: "upsert" as const,
@@ -54,6 +54,8 @@ 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);
@@ -36,17 +36,12 @@ 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: "",
log: "Wrapped up (turn limit)",
});
});
test("maps captured foreground lifecycle live and on replay", async () => {
@@ -58,18 +53,6 @@ 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));
@@ -164,7 +147,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
expect(mapping?.detail).toEqual({
type: "sub_agent",
description: undefined,
log: "",
log: "Wrapped up (turn limit)",
});
},
);
@@ -57,8 +57,7 @@ export const tintinwebPiSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: title,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running")
return {
@@ -85,10 +84,6 @@ 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: [
@@ -98,17 +93,8 @@ export const tintinwebPiSubagents: PiExtension = {
title,
description,
toolCallId: call.callId,
status: childStatus,
status: status(details.data.status),
},
...(answer
? [
{
type: "timeline" as const,
id: call.callId,
item: { type: "assistant_message" as const, text: answer },
},
]
: []),
],
childSessions,
};
@@ -124,8 +110,7 @@ export const tintinwebPiSubagents: PiExtension = {
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running" || !details.success) return { detail };
return {
@@ -1,3 +1,6 @@
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";
@@ -7,7 +10,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 children keep their actions after the parent finishes (#6253)", async () => {
test("background cards retain child 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({
@@ -59,6 +62,22 @@ test("background children keep their 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",
@@ -75,6 +94,9 @@ test("background children keep their 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();
@@ -581,6 +581,9 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
let fsCalls = 0;
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
await tick();
// Do not charge garbage left by repository setup to the event filter.
// Allocations and GC caused by the measured callback remain in its budget.
global.gc();
const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => {
await marker('FILTER_START');
hook.enable();
@@ -614,6 +617,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
[
"--expose-gc",
"--import",
"tsx",
...(tracing ? ["--trace-sync-io"] : []),