mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce306238b | ||
|
|
c1bcfd930b | ||
|
|
6953ba6506 | ||
|
|
a68a0c2c5f |
@@ -64,7 +64,7 @@ function resolveShouldFill(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!fillAvailableHeight) return false;
|
if (!fillAvailableHeight) return false;
|
||||||
const t = detail?.type;
|
const t = detail?.type;
|
||||||
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
|
return t === "shell" || t === "edit" || t === "write" || t === "read";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDetailStyles(
|
function useDetailStyles(
|
||||||
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSubAgentFallbackHeader(
|
|
||||||
subAgentType: string | null | undefined,
|
|
||||||
description: string | null | undefined,
|
|
||||||
fallbackText: string,
|
|
||||||
): string {
|
|
||||||
if (subAgentType && description) {
|
|
||||||
return `${subAgentType}: ${description}`;
|
|
||||||
}
|
|
||||||
return subAgentType ?? description ?? fallbackText;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubAgentDetailProps {
|
|
||||||
log: string;
|
|
||||||
childSessionId: string | null | undefined;
|
|
||||||
subAgentType: string | null | undefined;
|
|
||||||
description: string | null | undefined;
|
|
||||||
ds: DetailStyles;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubAgentActivityRow {
|
|
||||||
index: number;
|
|
||||||
toolName: string;
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedSubAgentLog {
|
|
||||||
actions: SubAgentActivityRow[];
|
|
||||||
remainingLog: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
|
|
||||||
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const toolName = match[1]?.trim();
|
|
||||||
if (!toolName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const summary = match[2]?.trim();
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
toolName,
|
|
||||||
...(summary ? { summary } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSubAgentLog(log: string): ParsedSubAgentLog {
|
|
||||||
const actions: SubAgentActivityRow[] = [];
|
|
||||||
const remainingLines: string[] = [];
|
|
||||||
for (const line of log.replace(/^\n+/, "").split("\n")) {
|
|
||||||
const normalizedLine = line.trim();
|
|
||||||
if (!normalizedLine) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
|
|
||||||
if (parsedAction) {
|
|
||||||
actions.push(parsedAction);
|
|
||||||
} else {
|
|
||||||
remainingLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
actions,
|
|
||||||
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
|
|
||||||
return (
|
|
||||||
<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 {
|
interface EditDetailProps {
|
||||||
diffLines: DiffLine[] | undefined;
|
diffLines: DiffLine[] | undefined;
|
||||||
ds: DetailStyles;
|
ds: DetailStyles;
|
||||||
@@ -636,19 +458,7 @@ function buildDetailSections(
|
|||||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (detail.type === "worktree_setup") return [];
|
if (detail.type === "worktree_setup" || detail.type === "sub_agent") 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") {
|
if (detail.type === "edit") {
|
||||||
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
||||||
}
|
}
|
||||||
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
shellPrompt: {
|
shellPrompt: {
|
||||||
color: theme.colors.foregroundMuted,
|
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: {
|
jsonScroll: {
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
|
|||||||
@@ -832,7 +832,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.common.placeholders.search).toBe("Search...");
|
expect(en.common.placeholders.search).toBe("Search...");
|
||||||
expect(en.common.empty.noResults).toBe("No results found");
|
expect(en.common.empty.noResults).toBe("No results found");
|
||||||
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
|
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");
|
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2013,7 +2013,6 @@ export const ar: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "خطأ",
|
error: "خطأ",
|
||||||
empty: "لا توجد تفاصيل إضافية متاحة",
|
empty: "لا توجد تفاصيل إضافية متاحة",
|
||||||
subAgentActivity: "نشاط الوكيل الفرعي",
|
|
||||||
input: "مدخل",
|
input: "مدخل",
|
||||||
output: "الإخراج",
|
output: "الإخراج",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2038,7 +2038,6 @@ export const en = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
empty: "No additional details available",
|
empty: "No additional details available",
|
||||||
subAgentActivity: "Sub-agent activity",
|
|
||||||
input: "Input",
|
input: "Input",
|
||||||
output: "Output",
|
output: "Output",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2061,7 +2061,6 @@ export const es: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
empty: "No hay detalles adicionales disponibles",
|
empty: "No hay detalles adicionales disponibles",
|
||||||
subAgentActivity: "Actividad de subagente",
|
|
||||||
input: "Aporte",
|
input: "Aporte",
|
||||||
output: "Producción",
|
output: "Producción",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2060,7 +2060,6 @@ export const fr: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Erreur",
|
error: "Erreur",
|
||||||
empty: "Aucun détail supplémentaire disponible",
|
empty: "Aucun détail supplémentaire disponible",
|
||||||
subAgentActivity: "Activité du sous-agent",
|
|
||||||
input: "Entrée",
|
input: "Entrée",
|
||||||
output: "Sortie",
|
output: "Sortie",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2033,7 +2033,6 @@ export const ja: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "エラー",
|
error: "エラー",
|
||||||
empty: "追加の詳細はありません",
|
empty: "追加の詳細はありません",
|
||||||
subAgentActivity: "サブエージェントアクティビティ",
|
|
||||||
input: "入力",
|
input: "入力",
|
||||||
output: "出力",
|
output: "出力",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2023,7 +2023,6 @@ export const ko: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "오류",
|
error: "오류",
|
||||||
empty: "추가 세부 정보가 없습니다",
|
empty: "추가 세부 정보가 없습니다",
|
||||||
subAgentActivity: "서브에이전트 활동",
|
|
||||||
input: "입력",
|
input: "입력",
|
||||||
output: "출력",
|
output: "출력",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Erro",
|
error: "Erro",
|
||||||
empty: "Nenhum detalhe adicional disponível",
|
empty: "Nenhum detalhe adicional disponível",
|
||||||
subAgentActivity: "Atividade do subagente",
|
|
||||||
input: "Entrada",
|
input: "Entrada",
|
||||||
output: "Saída",
|
output: "Saída",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2046,7 +2046,6 @@ export const ru: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Ошибка",
|
error: "Ошибка",
|
||||||
empty: "Дополнительные сведения отсутствуют",
|
empty: "Дополнительные сведения отсутствуют",
|
||||||
subAgentActivity: "Активность субагента",
|
|
||||||
input: "Входные данные",
|
input: "Входные данные",
|
||||||
output: "Выходные данные",
|
output: "Выходные данные",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "错误",
|
error: "错误",
|
||||||
empty: "没有可用的更多详情",
|
empty: "没有可用的更多详情",
|
||||||
subAgentActivity: "Sub-agent 活动",
|
|
||||||
input: "输入",
|
input: "输入",
|
||||||
output: "输出",
|
output: "输出",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
|
|||||||
return Boolean(detail.url || detail.result || detail.codeText);
|
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(
|
function hasMeaningfulPlainTextDetail(
|
||||||
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
|||||||
case "fetch":
|
case "fetch":
|
||||||
return hasMeaningfulFetchDetail(detail);
|
return hasMeaningfulFetchDetail(detail);
|
||||||
case "worktree_setup":
|
case "worktree_setup":
|
||||||
return false;
|
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
|
||||||
case "sub_agent":
|
case "sub_agent":
|
||||||
return hasMeaningfulSubAgentDetail(detail);
|
return false;
|
||||||
case "plain_text":
|
case "plain_text":
|
||||||
return hasMeaningfulPlainTextDetail(detail);
|
return hasMeaningfulPlainTextDetail(detail);
|
||||||
case "plan":
|
case "plan":
|
||||||
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
|
|||||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||||
error: unknown;
|
error: unknown;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (params.detail?.type === "worktree_setup") return false;
|
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
|
||||||
const isRunning = params.status === "running" || params.status === "executing";
|
const isRunning = params.status === "running" || params.status === "executing";
|
||||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export type ToolCallDetail =
|
|||||||
subAgentType?: string;
|
subAgentType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
childSessionId?: string;
|
childSessionId?: string;
|
||||||
log: string;
|
log?: string;
|
||||||
actions?: Array<{
|
actions?: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
|
|||||||
@@ -638,7 +638,9 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
|
|||||||
subAgentType: z.string().optional(),
|
subAgentType: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
childSessionId: z.string().optional(),
|
childSessionId: z.string().optional(),
|
||||||
log: z.string(),
|
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a
|
||||||
|
// launch marker; apps before v0.14.1 required `log`, so daemons still send it empty.
|
||||||
|
log: z.string().optional(),
|
||||||
// Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still
|
// 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
|
// emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those
|
||||||
// clients are no longer in the field.
|
// clients are no longer in the field.
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
|
||||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
const card = {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
callId: "call-sub-agent-1",
|
callId: "call-sub-agent-1",
|
||||||
name: "Task",
|
name: "Task",
|
||||||
@@ -212,12 +212,26 @@ describe("wire schema compatibility", () => {
|
|||||||
subAgentType: "Explore",
|
subAgentType: "Explore",
|
||||||
description: "Inspect repository structure",
|
description: "Inspect repository structure",
|
||||||
childSessionId: "child-session-1",
|
childSessionId: "child-session-1",
|
||||||
log: "[Read] README.md",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
expect(parsed.detail.actions).toEqual([]);
|
const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
|
||||||
|
expect(parsed.detail).toMatchObject({ log: "", actions: [] });
|
||||||
|
const { log: _log, ...withoutLog } = card.detail;
|
||||||
|
expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
|
||||||
|
detail: { type: "sub_agent", description: "Inspect repository structure" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
AgentTimelineItemPayloadSchema.parse({
|
||||||
|
...card,
|
||||||
|
detail: { ...card.detail, log: "[Read] README.md" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ detail: { log: "[Read] README.md" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("old clients parse agent snapshots with rewind capabilities", () => {
|
test("old clients parse agent snapshots with rewind capabilities", () => {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ second line'`,
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
|
it("lists each sub-agent as its launch marker", () => {
|
||||||
const timeline: AgentTimelineItem[] = [
|
const timeline: AgentTimelineItem[] = [
|
||||||
{ type: "assistant_message", text: "Before first child." },
|
{ type: "assistant_message", text: "Before first child." },
|
||||||
toolCallItem({
|
toolCallItem({
|
||||||
@@ -257,7 +257,7 @@ second line'`,
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Child one",
|
subAgentType: "Child one",
|
||||||
description: "First investigation",
|
description: "First investigation",
|
||||||
log: "[Assistant] First child result.",
|
log: "",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ type: "assistant_message", text: "Between children." },
|
{ type: "assistant_message", text: "Between children." },
|
||||||
@@ -268,7 +268,7 @@ second line'`,
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Child two",
|
subAgentType: "Child two",
|
||||||
description: "Second investigation",
|
description: "Second investigation",
|
||||||
log: "[Assistant] Second child result.",
|
log: "",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ type: "assistant_message", text: "After second child." },
|
{ type: "assistant_message", text: "After second child." },
|
||||||
@@ -279,10 +279,8 @@ second line'`,
|
|||||||
expect(result.split("\n")).toEqual([
|
expect(result.split("\n")).toEqual([
|
||||||
"[Assistant] Before first child.",
|
"[Assistant] Before first child.",
|
||||||
"[Child one] First investigation",
|
"[Child one] First investigation",
|
||||||
"[Assistant] First child result.",
|
|
||||||
"[Assistant] Between children.",
|
"[Assistant] Between children.",
|
||||||
"[Child two] Second investigation",
|
"[Child two] Second investigation",
|
||||||
"[Assistant] Second child result.",
|
|
||||||
"[Assistant] After second child.",
|
"[Assistant] After second child.",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -164,9 +164,6 @@ function curateProjectedActivityEntries(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
entries.push(formatToolCallEntry(item, options));
|
entries.push(formatToolCallEntry(item, options));
|
||||||
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
|
|
||||||
entries.push(activityEntry(item.detail.log.trim()));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "todo":
|
case "todo":
|
||||||
|
|||||||
@@ -313,7 +313,10 @@ export type ToolCallDetail =
|
|||||||
subAgentType?: string;
|
subAgentType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
childSessionId?: string;
|
childSessionId?: string;
|
||||||
log: string;
|
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
|
||||||
|
// require `log`; every producer sends it empty and carries the same tag. Optional here because
|
||||||
|
// protocol timeline items, where `log` is optional, flow into this type.
|
||||||
|
log?: string;
|
||||||
actions?: Array<{
|
actions?: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
|
|||||||
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "general-purpose",
|
subAgentType: "general-purpose",
|
||||||
description: "Count files here",
|
description: "Count files here",
|
||||||
log: "[Bash] ls -1",
|
log: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(["before", "after"])(
|
test.each(["before", "after"])(
|
||||||
"keeps one logged Task card when child tools start %s the parent finishes",
|
"keeps one Task card when child tools start %s the parent finishes",
|
||||||
async (timing) => {
|
async (timing) => {
|
||||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
// 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.
|
// 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",
|
type: "sub_agent",
|
||||||
subAgentType: "general-purpose",
|
subAgentType: "general-purpose",
|
||||||
description: "Count files here",
|
description: "Count files here",
|
||||||
log: "[Bash] ls -1",
|
log: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-25
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
queryFactory.mockReset();
|
queryFactory.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
|
||||||
const session = await new ClaudeAgentClient({
|
const session = await new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
queryFactory,
|
queryFactory,
|
||||||
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
|
|
||||||
expect(latest.detail.subAgentType).toBe("Explore");
|
expect(latest.detail.subAgentType).toBe("Explore");
|
||||||
expect(latest.detail.description).toBe("Inspect repository structure");
|
expect(latest.detail.description).toBe("Inspect repository structure");
|
||||||
expect(latest.detail.log).toContain("[Read] README.md");
|
expect(latest.detail.log).toBe("");
|
||||||
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) => ({
|
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
|
||||||
seq: index + 1,
|
seq: index + 1,
|
||||||
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
.map((event) => event.item)
|
.map((event) => event.item)
|
||||||
.at(-1);
|
.at(-1);
|
||||||
|
|
||||||
expect(latestSubAgentUpdate?.detail).toMatchObject({
|
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
|
||||||
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 () => {
|
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
|
||||||
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
|
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
test("keeps the Task card the same size while the child runs", async () => {
|
||||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||||
|
|
||||||
const session = await new ClaudeAgentClient({
|
const session = await new ClaudeAgentClient({
|
||||||
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||||
await session.close();
|
await session.close();
|
||||||
|
|
||||||
const timelineToolCalls = events
|
const runningCards = events
|
||||||
.filter(
|
.flatMap((event) =>
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
event.type === "timeline" &&
|
||||||
event.type === "timeline" && event.item.type === "tool_call",
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.callId === "task-tail-1" &&
|
||||||
|
event.item.status === "running" &&
|
||||||
|
event.item.detail.type === "sub_agent"
|
||||||
|
? [event.item]
|
||||||
|
: [],
|
||||||
)
|
)
|
||||||
.map((event) => event.item)
|
.map((item) => JSON.stringify(item));
|
||||||
.filter((item) => item.callId === "task-tail-1");
|
expect(runningCards).toHaveLength(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");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(latest.detail.log).not.toContain("[Read] file-1.md");
|
const childToolCalls = events.filter(
|
||||||
expect(latest.detail.log).not.toContain("[Read] file-5.md");
|
(event) =>
|
||||||
expect(latest.detail.log).toContain("[Read] file-6.md");
|
event.type === "provider_subagent" &&
|
||||||
expect(latest.detail.log).toContain("[Read] file-205.md");
|
event.event.type === "timeline" &&
|
||||||
|
event.event.id === "task-tail-1" &&
|
||||||
|
event.event.item.type === "tool_call",
|
||||||
|
);
|
||||||
|
expect(childToolCalls).toHaveLength(205);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a long main-thread tool call out of the subagents track", async () => {
|
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 Task card in the parent transcript, built from the declaration.
|
||||||
*
|
*
|
||||||
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
|
* The sidechain tracker also emits this card when it first sees the child, and the two
|
||||||
* collapse on the shared tool-call id. Emitting it at declaration matters because a
|
* 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
|
* 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".
|
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
|
||||||
@@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail(
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
...(facts.title ? { subAgentType: facts.title } : {}),
|
...(facts.title ? { subAgentType: facts.title } : {}),
|
||||||
...(facts.description ? { description: facts.description } : {}),
|
...(facts.description ? { description: facts.description } : {}),
|
||||||
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
log: "",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|||||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||||
|
|
||||||
describe("ClaudeSidechainTracker", () => {
|
describe("ClaudeSidechainTracker", () => {
|
||||||
it("retains a background child's actions across the parent finishing", () => {
|
it("keeps a background child's card across the parent finishing", () => {
|
||||||
const tracker = new ClaudeSidechainTracker({
|
const tracker = new ClaudeSidechainTracker({
|
||||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||||
isDescriptorOwnedElsewhere: () => true,
|
isDescriptorOwnedElsewhere: () => true,
|
||||||
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
|
|||||||
parent_tool_use_id: "task-1",
|
parent_tool_use_id: "task-1",
|
||||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||||
}) as unknown as SDKMessage;
|
}) as unknown as SDKMessage;
|
||||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||||
expect(tracker.finishAll("completed")).toEqual([]);
|
events.filter((event) => event.type === "timeline");
|
||||||
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||||
expect(events).toContainEqual(
|
events.flatMap((event) =>
|
||||||
expect.objectContaining({
|
event.type === "provider_subagent" &&
|
||||||
type: "timeline",
|
event.event.type === "timeline" &&
|
||||||
item: expect.objectContaining({
|
event.event.item.type === "tool_call"
|
||||||
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
|
? [event.event.item.callId]
|
||||||
}),
|
: [],
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
|
||||||
|
expect(tracker.finishAll("completed")).toEqual([]);
|
||||||
|
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||||
|
expect(parentCards(afterFinish)).toEqual([]);
|
||||||
|
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
|
||||||
|
|
||||||
tracker.delete("task-1");
|
tracker.delete("task-1");
|
||||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||||
expect(next).toContainEqual(
|
expect(parentCards(next)).toHaveLength(1);
|
||||||
expect.objectContaining({
|
expect(childCallIds(next)).toEqual(["next"]);
|
||||||
type: "timeline",
|
});
|
||||||
item: expect.objectContaining({
|
|
||||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
it("shows a streamed child tool call's input once the assistant message brings it", () => {
|
||||||
}),
|
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||||
}),
|
const frames = [
|
||||||
|
{
|
||||||
|
type: "stream_event",
|
||||||
|
parent_tool_use_id: "task-1",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
parent_tool_use_id: "task-1",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as SDKMessage[];
|
||||||
|
|
||||||
|
const childCards = frames
|
||||||
|
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
|
||||||
|
.flatMap((event) =>
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "timeline" &&
|
||||||
|
event.event.item.type === "tool_call"
|
||||||
|
? [event.event.item]
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(childCards.at(-1)).toMatchObject({
|
||||||
|
callId: "bash-1",
|
||||||
|
status: "running",
|
||||||
|
detail: { type: "shell", command: "echo child" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
mapClaudeFailedToolCall,
|
mapClaudeFailedToolCall,
|
||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./tool-call-mapper.js";
|
} from "./tool-call-mapper.js";
|
||||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
|
||||||
import {
|
import {
|
||||||
isClaudeSubagentHandbackToolName,
|
isClaudeSubagentHandbackToolName,
|
||||||
readClaudeSubagentHandback,
|
readClaudeSubagentHandback,
|
||||||
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
|
|||||||
|
|
||||||
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
|
||||||
interface SubAgentActionEntry {
|
interface SubAgentChildCall {
|
||||||
index: number;
|
|
||||||
toolName: string;
|
toolName: string;
|
||||||
input: unknown;
|
input: unknown;
|
||||||
summary?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubAgentActivityState {
|
interface SubAgentActivityState {
|
||||||
name?: string;
|
name?: string;
|
||||||
subAgentType?: string;
|
subAgentType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
actions: SubAgentActionEntry[];
|
/** The child's tool calls awaiting results, which carry no input of their own. */
|
||||||
actionKeys: string[];
|
runningCalls: Map<string, SubAgentChildCall>;
|
||||||
nextActionIndex: number;
|
|
||||||
actionIndexByKey: Map<string, number>;
|
|
||||||
completedActionKeys: Set<string>;
|
completedActionKeys: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
|
|||||||
input: unknown;
|
input: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
|
||||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||||
|
|
||||||
function readTrimmedString(value: unknown): string | undefined {
|
function readTrimmedString(value: unknown): string | undefined {
|
||||||
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
|
|||||||
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
|
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
|
||||||
/**
|
/**
|
||||||
* The parent's tool call once Claude has answered it. A background child streams its frames
|
* The parent's tool call once Claude has answered it. A background child streams its frames
|
||||||
* after that answer, so its card keeps the settled status and output and gains the child's
|
* after that answer, so its card keeps the settled status and output.
|
||||||
* action log.
|
|
||||||
*/
|
*/
|
||||||
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||||
}) {
|
}) {
|
||||||
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||||
|
const existingState = this.activeSidechains.get(parentToolUseId);
|
||||||
const state =
|
const state =
|
||||||
this.activeSidechains.get(parentToolUseId) ??
|
existingState ??
|
||||||
({
|
({
|
||||||
actions: [],
|
runningCalls: new Map<string, SubAgentChildCall>(),
|
||||||
actionKeys: [],
|
|
||||||
nextActionIndex: 1,
|
|
||||||
actionIndexByKey: new Map<string, number>(),
|
|
||||||
completedActionKeys: new Set<string>(),
|
completedActionKeys: new Set<string>(),
|
||||||
} satisfies SubAgentActivityState);
|
} satisfies SubAgentActivityState);
|
||||||
this.activeSidechains.set(parentToolUseId, state);
|
this.activeSidechains.set(parentToolUseId, state);
|
||||||
|
|
||||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
// The parent card is a launch marker: it changes only when the child is first seen or the
|
||||||
|
// task input names it differently, never with the child's activity.
|
||||||
|
const cardUpdated =
|
||||||
|
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
|
||||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||||
const childTimelineItems = [
|
const childTimelineItems = [
|
||||||
...this.extractSubAgentTimelineItems(message, state),
|
...this.extractSubAgentTimelineItems(message, state),
|
||||||
...this.extractSubAgentToolResults(message, state),
|
...this.extractSubAgentToolResults(message, state),
|
||||||
];
|
];
|
||||||
let actionUpdated = false;
|
|
||||||
for (const action of actionCandidates) {
|
for (const action of actionCandidates) {
|
||||||
if (state.completedActionKeys.has(action.key)) continue;
|
if (state.completedActionKeys.has(action.key)) continue;
|
||||||
if (this.appendSubAgentAction(state, action)) {
|
if (this.trackChildCall(state, action)) {
|
||||||
actionUpdated = true;
|
|
||||||
const toolCall = mapClaudeRunningToolCall({
|
const toolCall = mapClaudeRunningToolCall({
|
||||||
name: action.toolName,
|
name: action.toolName,
|
||||||
callId: action.key,
|
callId: action.key,
|
||||||
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
|
if (!cardUpdated && childTimelineItems.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||||
...(state.description ? { description: state.description } : {}),
|
...(state.description ? { description: state.description } : {}),
|
||||||
log: state.actions
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
.map((action) =>
|
log: "",
|
||||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
actions: [],
|
actions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,7 +159,8 @@ export class ClaudeSidechainTracker {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
|
const parentCard: AgentStreamEvent[] =
|
||||||
|
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
|
|||||||
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
|
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
|
||||||
const callId = readTrimmedString(block.tool_use_id);
|
const callId = readTrimmedString(block.tool_use_id);
|
||||||
if (!callId || state.completedActionKeys.has(callId)) continue;
|
if (!callId || state.completedActionKeys.has(callId)) continue;
|
||||||
const actionIndex = state.actionIndexByKey.get(callId);
|
const action = state.runningCalls.get(callId);
|
||||||
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
|
|
||||||
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
|
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
|
||||||
if (!toolName) continue;
|
if (!toolName) continue;
|
||||||
const params = {
|
const params = {
|
||||||
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
|
|||||||
? mapClaudeFailedToolCall({ ...params, error: block })
|
? mapClaudeFailedToolCall({ ...params, error: block })
|
||||||
: mapClaudeCompletedToolCall(params);
|
: mapClaudeCompletedToolCall(params);
|
||||||
if (toolCall) {
|
if (toolCall) {
|
||||||
|
state.runningCalls.delete(callId);
|
||||||
state.completedActionKeys.add(callId);
|
state.completedActionKeys.add(callId);
|
||||||
items.push(toolCall);
|
items.push(toolCall);
|
||||||
}
|
}
|
||||||
@@ -412,13 +402,12 @@ export class ClaudeSidechainTracker {
|
|||||||
const key =
|
const key =
|
||||||
readTrimmedString(block.id) ??
|
readTrimmedString(block.id) ??
|
||||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||||
return [
|
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
|
||||||
{
|
const input =
|
||||||
key,
|
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
|
||||||
toolName: block.name,
|
? block.input
|
||||||
input: block.input ?? null,
|
: null;
|
||||||
},
|
return [{ key, toolName: block.name, input }];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||||
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private appendSubAgentAction(
|
/** Returns whether the call is new or has gained its input, so its running card changes. */
|
||||||
|
private trackChildCall(
|
||||||
state: SubAgentActivityState,
|
state: SubAgentActivityState,
|
||||||
candidate: SubAgentActionCandidate,
|
candidate: SubAgentActionCandidate,
|
||||||
): boolean {
|
): boolean {
|
||||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
const toolName = readTrimmedString(candidate.toolName);
|
||||||
if (!normalizedToolName) {
|
if (!toolName) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const existing = state.runningCalls.get(candidate.key);
|
||||||
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
if (existing && (existing.input !== null || candidate.input === null)) {
|
||||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
|
||||||
|
|
||||||
if (existingIndex !== undefined) {
|
|
||||||
const existing = state.actions[existingIndex];
|
|
||||||
if (!existing) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nextSummary = existing.summary ?? summary;
|
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
|
||||||
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,12 +77,19 @@ CHILD_SENTINEL in your own response.
|
|||||||
|
|
||||||
const completedChildIndex = events.findIndex(
|
const completedChildIndex = events.findIndex(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.type === "timeline" &&
|
event.type === "provider_subagent" &&
|
||||||
event.item.type === "tool_call" &&
|
event.event.type === "upsert" &&
|
||||||
event.item.status === "completed" &&
|
event.event.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) =>
|
const rootMessageIndexes = events.flatMap((event, index) =>
|
||||||
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
|
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
|
||||||
);
|
);
|
||||||
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
|
|||||||
.map((item) => item.text)
|
.map((item) => item.text)
|
||||||
.join("");
|
.join("");
|
||||||
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
|
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
|
||||||
|
expect(childAssistantText).toContain("CHILD_SENTINEL");
|
||||||
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
|
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
|
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
|
||||||
expect(terminalIndexes).toHaveLength(1);
|
expect(terminalIndexes).toHaveLength(1);
|
||||||
|
|||||||
@@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
|
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -3562,7 +3562,6 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const timelineEvents = events.filter((event) => event.type === "timeline");
|
const timelineEvents = events.filter((event) => event.type === "timeline");
|
||||||
expect(timelineEvents).toHaveLength(4);
|
|
||||||
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
|
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
|
||||||
const finalItem = timelineEvents.at(-1)?.item;
|
const finalItem = timelineEvents.at(-1)?.item;
|
||||||
expect(finalItem).toMatchObject({
|
expect(finalItem).toMatchObject({
|
||||||
@@ -3574,7 +3573,7 @@ describe("Codex app-server provider", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Sub-agent",
|
subAgentType: "Sub-agent",
|
||||||
description: "Report findings.",
|
description: "Report findings.",
|
||||||
log: "[Assistant] Found the path.",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -3651,13 +3650,9 @@ describe("Codex app-server provider", () => {
|
|||||||
id: "settled-child-thread",
|
id: "settled-child-thread",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
});
|
});
|
||||||
expect(events.at(-1)).toMatchObject({
|
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
item: {
|
item: { callId: "call-settled-child", status: "completed" },
|
||||||
callId: "call-settled-child",
|
|
||||||
status: "completed",
|
|
||||||
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
appServer.completesSubAgentActivity({
|
appServer.completesSubAgentActivity({
|
||||||
@@ -3962,6 +3957,8 @@ describe("Codex app-server provider", () => {
|
|||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
async () => appServer.child,
|
async () => appServer.child,
|
||||||
);
|
);
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resultPromise = session.run("Delegate the investigation, then report the result.");
|
const resultPromise = session.run("Delegate the investigation, then report the result.");
|
||||||
@@ -4005,11 +4002,18 @@ describe("Codex app-server provider", () => {
|
|||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
callId: "spawn-child-early",
|
callId: "spawn-child-early",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: {
|
detail: { type: "sub_agent", log: "" },
|
||||||
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();
|
appServer.assertNoErrors();
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
await session.close();
|
||||||
@@ -4080,10 +4084,7 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(toolCalls.at(-1)).toMatchObject({
|
expect(toolCalls.at(-1)).toMatchObject({
|
||||||
callId: "spawn-child-stable",
|
callId: "spawn-child-stable",
|
||||||
status: "canceled",
|
status: "canceled",
|
||||||
detail: {
|
detail: { type: "sub_agent", log: "" },
|
||||||
type: "sub_agent",
|
|
||||||
log: "[Assistant] Still on the same card.",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
appServer.assertNoErrors();
|
appServer.assertNoErrors();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4163,11 +4164,10 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
|
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
|
||||||
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
|
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
|
||||||
expect(toolCalls.at(-1)).toMatchObject({
|
expect(toolCalls.at(-1)).toMatchObject({
|
||||||
callId: "spawn-child-late-compaction",
|
callId: "spawn-child-late-compaction",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
|
||||||
});
|
});
|
||||||
appServer.assertNoErrors();
|
appServer.assertNoErrors();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4175,7 +4175,7 @@ describe("Codex app-server provider", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("projects legacy child tools into one stable sub-agent log", async () => {
|
test("routes legacy child tools to the child timeline", async () => {
|
||||||
const appServer = createFakeCodexAppServer();
|
const appServer = createFakeCodexAppServer();
|
||||||
const session = new CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
createConfig({ cwd: "/workspace/project" }),
|
createConfig({ cwd: "/workspace/project" }),
|
||||||
@@ -4183,6 +4183,8 @@ describe("Codex app-server provider", () => {
|
|||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
async () => appServer.child,
|
async () => appServer.child,
|
||||||
);
|
);
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resultPromise = session.run("Delegate the implementation.");
|
const resultPromise = session.run("Delegate the implementation.");
|
||||||
@@ -4225,19 +4227,26 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(finalToolCall).toMatchObject({
|
expect(finalToolCall).toMatchObject({
|
||||||
callId: "spawn-legacy-tool-child",
|
callId: "spawn-legacy-tool-child",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: { type: "sub_agent" },
|
detail: { type: "sub_agent", log: "" },
|
||||||
});
|
});
|
||||||
if (finalToolCall?.detail.type === "sub_agent") {
|
const childToolCalls = events.flatMap((event) =>
|
||||||
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
|
event.type === "provider_subagent" &&
|
||||||
expect(finalToolCall.detail.log).toContain("[Edit]");
|
event.event.type === "timeline" &&
|
||||||
}
|
event.event.id === "legacy-tool-child" &&
|
||||||
|
event.event.item.type === "tool_call"
|
||||||
|
? [event.event.item]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
|
||||||
|
new Set(["shell", "edit"]),
|
||||||
|
);
|
||||||
appServer.assertNoErrors();
|
appServer.assertNoErrors();
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
await session.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
|
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -4297,8 +4306,22 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(beforeParentCompletes.at(-1)).toMatchObject({
|
expect(beforeParentCompletes.at(-1)).toMatchObject({
|
||||||
callId: "spawn-child-root",
|
callId: "spawn-child-root",
|
||||||
status: "running",
|
status: "running",
|
||||||
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
|
detail: { type: "sub_agent", log: "" },
|
||||||
});
|
});
|
||||||
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "provider_subagent",
|
||||||
|
event: {
|
||||||
|
type: "timeline",
|
||||||
|
id: "grandchild-thread",
|
||||||
|
item: {
|
||||||
|
type: "assistant_message",
|
||||||
|
messageId: "grandchild-message",
|
||||||
|
text: "Grandchild findings.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await asInternals(session).handleNotification("turn/completed", {
|
await asInternals(session).handleNotification("turn/completed", {
|
||||||
threadId: "child-thread-root",
|
threadId: "child-thread-root",
|
||||||
@@ -4365,13 +4388,20 @@ describe("Codex app-server provider", () => {
|
|||||||
"child-thread-root",
|
"child-thread-root",
|
||||||
"child-thread-root",
|
"child-thread-root",
|
||||||
]);
|
]);
|
||||||
expect(events.at(-1)).toMatchObject({
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "provider_subagent",
|
||||||
|
event: {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
|
id: "grandchild-thread",
|
||||||
item: {
|
item: {
|
||||||
callId: "spawn-child-root",
|
type: "assistant_message",
|
||||||
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
|
messageId: "grandchild-after-wait",
|
||||||
|
text: "Still nested.",
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("never treats an unmapped foreign terminal as the root terminal", async () => {
|
test("never treats an unmapped foreign terminal as the root terminal", async () => {
|
||||||
@@ -4791,7 +4821,7 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(events).toHaveLength(eventCountAfterSpawn);
|
expect(events).toHaveLength(eventCountAfterSpawn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps child context compaction inside the child card", async () => {
|
test("keeps child context compaction on the child timeline", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -4824,10 +4854,70 @@ describe("Codex app-server provider", () => {
|
|||||||
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
|
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(timelineItems.at(-1)).toMatchObject({
|
expect(
|
||||||
type: "tool_call",
|
events.flatMap((event) =>
|
||||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
id: "child-thread-compaction",
|
||||||
|
item: { type: "compaction", status: "loading" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
id: "child-thread-compaction",
|
||||||
|
item: { type: "compaction", status: "completed" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
for (const child of ["compacted-child", "turn-ended-child"]) {
|
||||||
|
await asInternals(session).handleNotification("item/completed", {
|
||||||
|
threadId: "test-thread",
|
||||||
|
item: {
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: `spawn-${child}`,
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: child,
|
||||||
|
agentPath: `/root/${child}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await asInternals(session).handleNotification("item/started", {
|
||||||
|
threadId: child,
|
||||||
|
item: { type: "contextCompaction", id: `${child}-compaction` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await asInternals(session).handleNotification("thread/compacted", {
|
||||||
|
threadId: "compacted-child",
|
||||||
|
});
|
||||||
|
await asInternals(session).handleNotification("item/completed", {
|
||||||
|
threadId: "compacted-child",
|
||||||
|
item: { type: "contextCompaction", id: "compacted-child-compaction" },
|
||||||
|
});
|
||||||
|
await asInternals(session).handleNotification("turn/completed", {
|
||||||
|
threadId: "turn-ended-child",
|
||||||
|
turn: { status: "completed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactions = events.flatMap((event) =>
|
||||||
|
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||||
|
? [
|
||||||
|
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
expect(compactions).toEqual([
|
||||||
|
"compacted-child loading",
|
||||||
|
"turn-ended-child loading",
|
||||||
|
"compacted-child completed",
|
||||||
|
"turn-ended-child completed",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
|
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
|
||||||
@@ -4862,7 +4952,7 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(events.at(-1)?.item).toMatchObject({
|
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
callId: "call-sub-agent-child-command-failure",
|
callId: "call-sub-agent-child-command-failure",
|
||||||
name: "Sub-agent",
|
name: "Sub-agent",
|
||||||
@@ -5157,7 +5247,7 @@ describe("Codex app-server provider", () => {
|
|||||||
);
|
);
|
||||||
expect(liveToolCalls.at(-1)).toMatchObject({
|
expect(liveToolCalls.at(-1)).toMatchObject({
|
||||||
status: "running",
|
status: "running",
|
||||||
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
|
detail: { type: "sub_agent", log: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
liveEvents.length = 0;
|
liveEvents.length = 0;
|
||||||
@@ -5170,12 +5260,12 @@ describe("Codex app-server provider", () => {
|
|||||||
itemId: "legacy-child-message-after-resume",
|
itemId: "legacy-child-message-after-resume",
|
||||||
delta: "Legacy findings after resume.",
|
delta: "Legacy findings after resume.",
|
||||||
});
|
});
|
||||||
expect(liveEvents.at(-1)).toMatchObject({
|
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
item: {
|
item: {
|
||||||
callId: "legacy-spawn-history",
|
callId: "legacy-spawn-history",
|
||||||
status: "running",
|
status: "running",
|
||||||
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
|
detail: { type: "sub_agent", log: "" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ import { Dirent } from "node:fs";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import { curateAgentActivity } from "../activity-curator.js";
|
|
||||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||||
import {
|
import {
|
||||||
mapCodexToolCallEnvelope,
|
mapCodexToolCallEnvelope,
|
||||||
@@ -3384,8 +3384,6 @@ interface CodexSubAgentCallState {
|
|||||||
activityItemIds: Set<string>;
|
activityItemIds: Set<string>;
|
||||||
pendingCommandOutputDeltas: Map<string, string[]>;
|
pendingCommandOutputDeltas: Map<string, string[]>;
|
||||||
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
||||||
childItemOrder: string[];
|
|
||||||
childItems: Map<string, AgentTimelineItem>;
|
|
||||||
childThreadIds: Set<string>;
|
childThreadIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3487,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||||
private pendingRootCompactionItemIds = new Set<string>();
|
private pendingRootCompactionItemIds = new Set<string>();
|
||||||
private pendingAnonymousRootCompactions = 0;
|
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:
|
// Codex can report one completed compaction through both channels:
|
||||||
// `thread/compacted` and a completed `contextCompaction` item.
|
// `thread/compacted` and a completed `contextCompaction` item.
|
||||||
private unpairedCompactionNotificationCompletions = 0;
|
private unpairedCompactionNotificationCompletions = 0;
|
||||||
@@ -5516,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
case "patch_apply_completed":
|
case "patch_apply_completed":
|
||||||
this.handlePatchApplyCompletedNotification(parsed, callId);
|
this.handlePatchApplyCompletedNotification(parsed, callId);
|
||||||
return;
|
return;
|
||||||
|
case "context_compacted":
|
||||||
|
this.settleChildCompaction(parsed.threadId);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
// Aggregate child telemetry is redundant and must not leak into the
|
// Aggregate child telemetry is redundant and must not leak into the
|
||||||
// root timeline. Concrete legacy tools are projected above for Codex
|
// root timeline. Concrete legacy tools are projected above for Codex
|
||||||
@@ -5692,20 +5696,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
activityItemIds: new Set<string>(),
|
activityItemIds: new Set<string>(),
|
||||||
pendingCommandOutputDeltas: new Map<string, string[]>(),
|
pendingCommandOutputDeltas: new Map<string, string[]>(),
|
||||||
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
||||||
childItemOrder: [],
|
|
||||||
childItems: new Map<string, AgentTimelineItem>(),
|
|
||||||
childThreadIds: new Set<string>(),
|
childThreadIds: new Set<string>(),
|
||||||
} satisfies CodexSubAgentCallState);
|
} satisfies CodexSubAgentCallState);
|
||||||
|
|
||||||
state.toolCall = {
|
state.toolCall = timelineItem;
|
||||||
...timelineItem,
|
|
||||||
detail: {
|
|
||||||
...timelineItem.detail,
|
|
||||||
log:
|
|
||||||
timelineItem.detail.log ||
|
|
||||||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.parentCallId ??= parentCallId;
|
state.parentCallId ??= parentCallId;
|
||||||
state.parentSubagentId ??= parentSubagentId;
|
state.parentSubagentId ??= parentSubagentId;
|
||||||
const activity = readCodexSubAgentActivity(rawItem);
|
const activity = readCodexSubAgentActivity(rawItem);
|
||||||
@@ -5758,25 +5752,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
||||||
cwd: this.config.cwd ?? null,
|
cwd: this.config.cwd ?? null,
|
||||||
});
|
});
|
||||||
if (
|
const subAgentType =
|
||||||
activityToolCall?.detail.type === "sub_agent" &&
|
activityToolCall?.detail.type === "sub_agent"
|
||||||
state.toolCall.detail.type === "sub_agent"
|
? activityToolCall.detail.subAgentType
|
||||||
) {
|
: undefined;
|
||||||
state.toolCall = {
|
|
||||||
...state.toolCall,
|
|
||||||
detail: {
|
|
||||||
...state.toolCall.detail,
|
|
||||||
subAgentType: activityToolCall.detail.subAgentType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||||
if (activity.kind === "interrupted") {
|
if (activity.kind === "interrupted") {
|
||||||
nextStatus = "canceled";
|
nextStatus = "canceled";
|
||||||
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||||
nextStatus = undefined;
|
nextStatus = undefined;
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5817,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (
|
if (
|
||||||
childSubAgentCallId &&
|
childSubAgentCallId &&
|
||||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
|
parsed.threadId &&
|
||||||
|
this.handleSubAgentContextCompactionItem(
|
||||||
|
childSubAgentCallId,
|
||||||
|
parsed.threadId,
|
||||||
|
parsed.item,
|
||||||
|
"completed",
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -5827,17 +5819,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
|
|
||||||
const state = this.subAgentCallsByCallId.get(callId);
|
|
||||||
if (!state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.childItems.has(itemId)) {
|
|
||||||
state.childItemOrder.push(itemId);
|
|
||||||
}
|
|
||||||
state.childItems.set(itemId, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitCodexToolTimelineItem(
|
private emitCodexToolTimelineItem(
|
||||||
timelineItem: ToolCallTimelineItem,
|
timelineItem: ToolCallTimelineItem,
|
||||||
subAgentCallId: string | null,
|
subAgentCallId: string | null,
|
||||||
@@ -5847,7 +5828,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
|
|
||||||
const state = this.subAgentCallsByCallId.get(subAgentCallId);
|
const state = this.subAgentCallsByCallId.get(subAgentCallId);
|
||||||
if (state) {
|
if (state) {
|
||||||
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
|
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
|
||||||
@@ -5861,26 +5841,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
|
|
||||||
return state.childItemOrder
|
|
||||||
.map((itemId) => state.childItems.get(itemId))
|
|
||||||
.filter((item): item is AgentTimelineItem => Boolean(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitSubAgentActivityUpdate(
|
private emitSubAgentActivityUpdate(
|
||||||
callId: string,
|
callId: string,
|
||||||
status?: ToolCallTimelineItem["status"],
|
status?: ToolCallTimelineItem["status"],
|
||||||
options?: { reopen?: boolean },
|
options?: { reopen?: boolean; subAgentType?: string },
|
||||||
): void {
|
): void {
|
||||||
const state = this.subAgentCallsByCallId.get(callId);
|
const state = this.subAgentCallsByCallId.get(callId);
|
||||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const childTimeline = this.getSubAgentChildTimeline(state);
|
|
||||||
const log =
|
|
||||||
childTimeline.length > 0
|
|
||||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
|
||||||
: "";
|
|
||||||
let resolvedStatus = status ?? state.toolCall.status;
|
let resolvedStatus = status ?? state.toolCall.status;
|
||||||
if (
|
if (
|
||||||
status === "running" &&
|
status === "running" &&
|
||||||
@@ -5889,16 +5858,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
) {
|
) {
|
||||||
resolvedStatus = state.toolCall.status;
|
resolvedStatus = state.toolCall.status;
|
||||||
}
|
}
|
||||||
for (const childThreadId of state.childThreadIds) {
|
const baseToolCall = options?.subAgentType
|
||||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
? {
|
||||||
}
|
|
||||||
const baseToolCall = {
|
|
||||||
...state.toolCall,
|
...state.toolCall,
|
||||||
detail: {
|
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
|
||||||
...state.toolCall.detail,
|
}
|
||||||
log,
|
: state.toolCall;
|
||||||
},
|
|
||||||
};
|
|
||||||
const nextToolCall: ToolCallTimelineItem =
|
const nextToolCall: ToolCallTimelineItem =
|
||||||
resolvedStatus === "failed"
|
resolvedStatus === "failed"
|
||||||
? {
|
? {
|
||||||
@@ -5911,10 +5876,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
status: resolvedStatus,
|
status: resolvedStatus,
|
||||||
error: null,
|
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;
|
state.toolCall = nextToolCall;
|
||||||
if (state.parentCallId && state.parentCallId !== callId) {
|
for (const childThreadId of state.childThreadIds) {
|
||||||
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
|
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||||
this.emitSubAgentActivityUpdate(state.parentCallId);
|
}
|
||||||
|
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
|
||||||
@@ -6022,7 +5991,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
this.upsertSubAgentChildItem(callId, itemId, timelineItem);
|
|
||||||
this.pendingAgentMessages.delete(itemId);
|
this.pendingAgentMessages.delete(itemId);
|
||||||
this.pendingReasoning.delete(itemId);
|
this.pendingReasoning.delete(itemId);
|
||||||
this.pendingCommandOutputDeltas.delete(itemId);
|
this.pendingCommandOutputDeltas.delete(itemId);
|
||||||
@@ -6033,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private handleSubAgentContextCompactionItem(
|
private handleSubAgentContextCompactionItem(
|
||||||
callId: string,
|
callId: string,
|
||||||
|
childThreadId: string,
|
||||||
item: { id?: string; type?: string; [key: string]: unknown },
|
item: { id?: string; type?: string; [key: string]: unknown },
|
||||||
status: "loading" | "completed",
|
status: "loading" | "completed",
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.isContextCompactionItem(item)) {
|
if (!this.isContextCompactionItem(item)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (item.id) {
|
if (status === "loading") {
|
||||||
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
|
this.pendingChildCompactionThreadIds.add(childThreadId);
|
||||||
|
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
|
||||||
|
} else {
|
||||||
|
this.settleChildCompaction(childThreadId);
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(callId);
|
this.emitSubAgentActivityUpdate(callId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private settleChildCompaction(childThreadId: string): void {
|
||||||
|
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
|
||||||
|
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private shouldSkipCompletedThreadItem(
|
private shouldSkipCompletedThreadItem(
|
||||||
timelineItem: AgentTimelineItem,
|
timelineItem: AgentTimelineItem,
|
||||||
normalizedItemType: string | undefined,
|
normalizedItemType: string | undefined,
|
||||||
@@ -6076,11 +6054,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
text: parsed.delta,
|
text: parsed.delta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
|
||||||
type: "assistant_message",
|
|
||||||
messageId: parsed.itemId,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6114,10 +6087,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
text: parsed.delta,
|
text: parsed.delta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
|
||||||
type: "reasoning",
|
|
||||||
text: prev.join(""),
|
|
||||||
});
|
|
||||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6185,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (subAgentCallId) {
|
if (subAgentCallId) {
|
||||||
|
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
|
||||||
let status: ToolCallTimelineItem["status"] = "completed";
|
let status: ToolCallTimelineItem["status"] = "completed";
|
||||||
if (parsed.status === "failed") {
|
if (parsed.status === "failed") {
|
||||||
status = "failed";
|
status = "failed";
|
||||||
@@ -6771,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (
|
if (
|
||||||
childSubAgentCallId &&
|
childSubAgentCallId &&
|
||||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
|
parsed.threadId &&
|
||||||
|
this.handleSubAgentContextCompactionItem(
|
||||||
|
childSubAgentCallId,
|
||||||
|
parsed.threadId,
|
||||||
|
parsed.item,
|
||||||
|
"loading",
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6802,9 +6778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
if (childSubAgentCallId) {
|
if (childSubAgentCallId) {
|
||||||
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
|
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
|
||||||
if (parsed.item.id) {
|
|
||||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
|
||||||
}
|
|
||||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||||
return;
|
return;
|
||||||
@@ -6858,9 +6831,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (parsed.threadId) {
|
if (parsed.threadId) {
|
||||||
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
|
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
|
||||||
}
|
}
|
||||||
if (itemId) {
|
|
||||||
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
|
||||||
}
|
|
||||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem(
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Sub-agent",
|
subAgentType: "Sub-agent",
|
||||||
...(item.prompt ? { description: item.prompt } : {}),
|
...(item.prompt ? { description: item.prompt } : {}),
|
||||||
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
log: "",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
};
|
};
|
||||||
@@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem(
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: nativeName,
|
subAgentType: nativeName,
|
||||||
description,
|
description,
|
||||||
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
log: "",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ import {
|
|||||||
type OmpRuntimeOptions,
|
type OmpRuntimeOptions,
|
||||||
} from "./provider-config.js";
|
} from "./provider-config.js";
|
||||||
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
||||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||||
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||||
import { getUserMessageText } from "./message-history.js";
|
import { getUserMessageText } from "./message-history.js";
|
||||||
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
|
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
|
||||||
@@ -130,7 +130,6 @@ export interface OmpAgentClientOptions {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
runtime?: OmpRuntime;
|
runtime?: OmpRuntime;
|
||||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
|
||||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||||
noTurnScheduler?: OmpNoTurnScheduler;
|
noTurnScheduler?: OmpNoTurnScheduler;
|
||||||
usagePollScheduler?: OmpUsagePollScheduler;
|
usagePollScheduler?: OmpUsagePollScheduler;
|
||||||
@@ -205,7 +204,6 @@ interface OmpAgentSessionOptions {
|
|||||||
initialState: OmpSessionState;
|
initialState: OmpSessionState;
|
||||||
currentModeId?: string | null;
|
currentModeId?: string | null;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
|
||||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||||
noTurnScheduler?: OmpNoTurnScheduler;
|
noTurnScheduler?: OmpNoTurnScheduler;
|
||||||
usagePollScheduler?: OmpUsagePollScheduler;
|
usagePollScheduler?: OmpUsagePollScheduler;
|
||||||
@@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
this.logger.debug({ err: error }, "OMP context usage poll failed");
|
this.logger.debug({ err: error }, "OMP context usage poll failed");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.subagentCardTracker = new OmpSubagentCardTracker({
|
this.subagentCardTracker = new OmpSubagentCardTracker();
|
||||||
scheduler: options.subagentCardScheduler,
|
|
||||||
});
|
|
||||||
this.lastKnownThinkingOptionId =
|
this.lastKnownThinkingOptionId =
|
||||||
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
|
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
|
||||||
this.state.thinkingLevel ??
|
this.state.thinkingLevel ??
|
||||||
@@ -2155,8 +2151,7 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
return mapOmpToolDetail(toolCall, result, {
|
return mapOmpToolDetail(toolCall, result, {
|
||||||
toolCallId,
|
toolCallId,
|
||||||
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
|
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
|
||||||
mapSubagentDetail: (detail) =>
|
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
|
||||||
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2246,7 +2241,6 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
|
|
||||||
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
|
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||||
private readonly noTurnScheduler?: OmpNoTurnScheduler;
|
private readonly noTurnScheduler?: OmpNoTurnScheduler;
|
||||||
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
||||||
@@ -2265,7 +2259,6 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
);
|
);
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.runtimeSettings = runtimeSettings;
|
this.runtimeSettings = runtimeSettings;
|
||||||
this.subagentCardScheduler = options.subagentCardScheduler;
|
|
||||||
this.providerIdleScheduler = options.providerIdleScheduler;
|
this.providerIdleScheduler = options.providerIdleScheduler;
|
||||||
this.noTurnScheduler = options.noTurnScheduler;
|
this.noTurnScheduler = options.noTurnScheduler;
|
||||||
this.usagePollScheduler = options.usagePollScheduler;
|
this.usagePollScheduler = options.usagePollScheduler;
|
||||||
@@ -2349,7 +2342,6 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
initialState,
|
initialState,
|
||||||
currentModeId: launchMode.modeId,
|
currentModeId: launchMode.modeId,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
subagentCardScheduler: this.subagentCardScheduler,
|
|
||||||
providerIdleScheduler: this.providerIdleScheduler,
|
providerIdleScheduler: this.providerIdleScheduler,
|
||||||
noTurnScheduler: this.noTurnScheduler,
|
noTurnScheduler: this.noTurnScheduler,
|
||||||
usagePollScheduler: this.usagePollScheduler,
|
usagePollScheduler: this.usagePollScheduler,
|
||||||
@@ -2421,7 +2413,6 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
initialState,
|
initialState,
|
||||||
currentModeId: launchMode.modeId,
|
currentModeId: launchMode.modeId,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
subagentCardScheduler: this.subagentCardScheduler,
|
|
||||||
providerIdleScheduler: this.providerIdleScheduler,
|
providerIdleScheduler: this.providerIdleScheduler,
|
||||||
noTurnScheduler: this.noTurnScheduler,
|
noTurnScheduler: this.noTurnScheduler,
|
||||||
usagePollScheduler: this.usagePollScheduler,
|
usagePollScheduler: this.usagePollScheduler,
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
|
|||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Inspect files",
|
description: "Inspect files",
|
||||||
childSessionId: "/tmp/omp-task/Explore.jsonl",
|
childSessionId: "/tmp/omp-task/Explore.jsonl",
|
||||||
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
|
log: "",
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||||
|
|
||||||
const PARENT_TOOL_CALL_ID = "task-1";
|
const PARENT_TOOL_CALL_ID = "task-1";
|
||||||
@@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
class ManualScheduler implements OmpSubagentCardScheduler {
|
|
||||||
private currentMs = 0;
|
|
||||||
private nextId = 1;
|
|
||||||
private readonly timers = new Map<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", () => {
|
describe("OmpSubagentCardTracker", () => {
|
||||||
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
|
test("keeps the card a launch marker while the child runs", () => {
|
||||||
const scheduler = new ManualScheduler();
|
|
||||||
const emitted: string[] = [];
|
const emitted: string[] = [];
|
||||||
const tracker = new OmpSubagentCardTracker({
|
const emit = (toolCallId: string) => {
|
||||||
scheduler,
|
|
||||||
emitToolCall: (toolCallId) => {
|
|
||||||
emitted.push(toolCallId);
|
emitted.push(toolCallId);
|
||||||
return true;
|
return true;
|
||||||
},
|
};
|
||||||
});
|
const tracker = new OmpSubagentCardTracker();
|
||||||
tracker.handleLifecycle(LIFECYCLE);
|
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||||
for (const payload of PROGRESS) {
|
for (const payload of PROGRESS) {
|
||||||
tracker.handleProgress(payload);
|
tracker.handleProgress(payload, emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||||
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
|
||||||
type: "sub_agent",
|
|
||||||
subAgentType: "task",
|
|
||||||
description: "Task arg description wins",
|
|
||||||
log: "",
|
|
||||||
});
|
|
||||||
expect(detailBeforeTrailing).toEqual({
|
|
||||||
type: "sub_agent",
|
|
||||||
subAgentType: "task",
|
|
||||||
description: "Task arg description wins",
|
|
||||||
childSessionId: SESSION_FILE,
|
|
||||||
log: [
|
|
||||||
"EchoSubagent started",
|
|
||||||
"[bash] echo subagent-hi",
|
|
||||||
"[yield]",
|
|
||||||
"EchoSubagent completed",
|
|
||||||
].join("\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduler.advance(500);
|
|
||||||
|
|
||||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aggregates batch progress streams into one index-prefixed log", () => {
|
|
||||||
const scheduler = new ManualScheduler();
|
|
||||||
const emitted: string[] = [];
|
|
||||||
const tracker = new OmpSubagentCardTracker({
|
|
||||||
scheduler,
|
|
||||||
emitToolCall: (toolCallId) => {
|
|
||||||
emitted.push(toolCallId);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
tracker.handleLifecycle({
|
|
||||||
id: "Explore",
|
|
||||||
agent: "task",
|
|
||||||
description: "Inspect files",
|
|
||||||
status: "started",
|
|
||||||
sessionFile: "/tmp/one.jsonl",
|
|
||||||
parentToolCallId: "task.batch-1",
|
|
||||||
index: 0,
|
|
||||||
});
|
|
||||||
scheduler.advance(600);
|
|
||||||
tracker.handleProgress({
|
|
||||||
index: 5,
|
|
||||||
agent: "task",
|
|
||||||
task: "Run tests",
|
|
||||||
parentToolCallId: "task.batch-1",
|
|
||||||
progress: {
|
|
||||||
id: "Test",
|
|
||||||
status: "running",
|
|
||||||
description: "Run tests",
|
|
||||||
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
|
|
||||||
},
|
|
||||||
sessionFile: "/tmp/two.jsonl",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
|
|
||||||
expect(
|
expect(
|
||||||
tracker.detailFor("task.batch-1", {
|
tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "batch",
|
subAgentType: "task",
|
||||||
actions: [],
|
description: "Task arg description wins",
|
||||||
log: "",
|
log: "",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "batch",
|
subAgentType: "task",
|
||||||
description: "Inspect files",
|
description: "Task arg description wins",
|
||||||
childSessionId: "/tmp/one.jsonl",
|
childSessionId: SESSION_FILE,
|
||||||
log: "[1/6] Explore started\n[6/6] [bash] npm test",
|
log: "",
|
||||||
actions: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
|
test("re-emits the card when a later event names the child", () => {
|
||||||
const scheduler = new ManualScheduler();
|
|
||||||
const emitted: string[] = [];
|
const emitted: string[] = [];
|
||||||
const tracker = new OmpSubagentCardTracker({
|
const emit = (toolCallId: string) => {
|
||||||
scheduler,
|
|
||||||
emitToolCall: (toolCallId) => {
|
|
||||||
emitted.push(toolCallId);
|
emitted.push(toolCallId);
|
||||||
return true;
|
return true;
|
||||||
},
|
};
|
||||||
|
const tracker = new OmpSubagentCardTracker();
|
||||||
|
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
|
||||||
|
tracker.handleProgress(PROGRESS[0], emit);
|
||||||
|
|
||||||
|
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
|
||||||
|
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||||
|
type: "sub_agent",
|
||||||
|
childSessionId: SESSION_FILE,
|
||||||
|
log: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
tracker.handleLifecycle({
|
tracker.delete(PARENT_TOOL_CALL_ID);
|
||||||
id: "Explore",
|
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||||
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",
|
type: "sub_agent",
|
||||||
log: "",
|
log: "",
|
||||||
}).log,
|
});
|
||||||
).toBe("Explore started\nfound target file");
|
});
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
tracker.delete("task-1");
|
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||||
scheduler.advance(500);
|
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||||
|
|
||||||
expect(emitted).toEqual(["task-1"]);
|
|
||||||
expect(
|
|
||||||
tracker.detailFor("task-1", {
|
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
log: "static",
|
description: "Run echo in subagent",
|
||||||
}).log,
|
childSessionId: SESSION_FILE,
|
||||||
).toBe("static");
|
log: "",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,361 +1,106 @@
|
|||||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||||
|
|
||||||
export interface OmpSubagentCardTimer {
|
interface OmpSubagentCardChild {
|
||||||
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;
|
index: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
agent?: string;
|
|
||||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
|
||||||
childSessionId?: string;
|
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 {
|
export class OmpSubagentCardTracker {
|
||||||
private readonly scheduler: OmpSubagentCardScheduler;
|
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
|
||||||
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(
|
handleLifecycle(
|
||||||
payload: OmpSubagentLifecyclePayload,
|
payload: OmpSubagentLifecyclePayload,
|
||||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
emitToolCall: (toolCallId: string) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
this.update(
|
||||||
if (!parentToolCallId) {
|
payload.parentToolCallId,
|
||||||
return;
|
{
|
||||||
}
|
|
||||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
|
||||||
const item = this.upsertItem(parentToolCallId, {
|
|
||||||
index: payload.index,
|
index: payload.index,
|
||||||
agent: payload.agent,
|
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
status: payload.status === "started" ? "running" : payload.status,
|
|
||||||
childSessionId: payload.sessionFile,
|
childSessionId: payload.sessionFile,
|
||||||
});
|
},
|
||||||
this.appendLine(
|
emitToolCall,
|
||||||
item,
|
|
||||||
`lifecycle:${payload.status}:${payload.id}`,
|
|
||||||
`${payload.id} ${payload.status}`,
|
|
||||||
);
|
);
|
||||||
this.requestEmit(parentToolCallId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProgress(
|
handleProgress(
|
||||||
payload: OmpSubagentProgressPayload,
|
payload: OmpSubagentProgressPayload,
|
||||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
emitToolCall: (toolCallId: string) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
this.update(
|
||||||
if (!parentToolCallId) {
|
payload.parentToolCallId,
|
||||||
return;
|
{
|
||||||
}
|
|
||||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
|
||||||
const item = this.upsertItem(parentToolCallId, {
|
|
||||||
index: payload.index,
|
index: payload.index,
|
||||||
agent: payload.agent,
|
|
||||||
description: payload.progress.description,
|
description: payload.progress.description,
|
||||||
status: payload.progress.status,
|
|
||||||
childSessionId: payload.sessionFile,
|
childSessionId: payload.sessionFile,
|
||||||
});
|
},
|
||||||
|
emitToolCall,
|
||||||
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 {
|
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
|
||||||
if (baseDetail.type !== "sub_agent") {
|
if (baseDetail.type !== "sub_agent") {
|
||||||
return baseDetail;
|
return baseDetail;
|
||||||
}
|
}
|
||||||
const state = this.states.get(toolCallId);
|
const first = this.firstChildren.get(toolCallId);
|
||||||
if (!state) {
|
if (!first) {
|
||||||
return baseDetail;
|
return baseDetail;
|
||||||
}
|
}
|
||||||
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
|
const description = baseDetail.description ?? first.description;
|
||||||
const firstItem = items[0];
|
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
|
||||||
if (!firstItem) {
|
return {
|
||||||
return baseDetail;
|
...baseDetail,
|
||||||
}
|
...(description ? { description } : {}),
|
||||||
|
...(childSessionId ? { childSessionId } : {}),
|
||||||
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 {
|
delete(toolCallId: string): void {
|
||||||
const state = this.states.get(toolCallId);
|
this.firstChildren.delete(toolCallId);
|
||||||
if (state?.trailingTimer) {
|
|
||||||
this.scheduler.clearTimeout(state.trailingTimer);
|
|
||||||
}
|
|
||||||
this.states.delete(toolCallId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
for (const toolCallId of this.states.keys()) {
|
this.firstChildren.clear();
|
||||||
this.delete(toolCallId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private upsertItem(
|
private update(
|
||||||
parentToolCallId: string,
|
parentToolCallId: string | undefined,
|
||||||
input: {
|
input: { index: number; description?: string; childSessionId?: string },
|
||||||
index: number;
|
emitToolCall: (toolCallId: string) => boolean,
|
||||||
agent?: string;
|
): void {
|
||||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
const toolCallId = readTrimmedString(parentToolCallId);
|
||||||
description?: string;
|
if (!toolCallId) {
|
||||||
childSessionId?: string;
|
return;
|
||||||
},
|
|
||||||
): 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);
|
||||||
const item: OmpSubagentCardItem = {
|
if (first && input.index > first.index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = first?.index === input.index ? first : undefined;
|
||||||
|
const next: OmpSubagentCardChild = {
|
||||||
index: input.index,
|
index: input.index,
|
||||||
lines: [],
|
description: readTrimmedString(input.description) ?? existing?.description,
|
||||||
lineKeys: new Set<string>(),
|
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
|
||||||
...(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) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
state.items.set(input.index, item);
|
if (
|
||||||
return item;
|
existing &&
|
||||||
}
|
existing.description === next.description &&
|
||||||
|
existing.childSessionId === next.childSessionId
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
item.lines.push({ key, text: normalizedText });
|
this.firstChildren.set(toolCallId, next);
|
||||||
item.lineKeys.add(key);
|
emitToolCall(toolCallId);
|
||||||
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 {
|
function readTrimmedString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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();
|
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses task result text and transcript path as the best static replay detail", () => {
|
test("takes the child session from the task transcript path", () => {
|
||||||
expect(
|
expect(
|
||||||
mapOmpToolDetail(
|
mapOmpToolDetail(
|
||||||
parseToolArgs("task", {
|
parseToolArgs("task", {
|
||||||
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
|
|||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Inspect the target files",
|
description: "Inspect the target files",
|
||||||
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
|
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
|
||||||
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
|
log: "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([
|
|||||||
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
|
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
|
||||||
const argRecord = isRecord(args) ? args : {};
|
const argRecord = isRecord(args) ? args : {};
|
||||||
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
|
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
|
||||||
const resultText = extractTextFromToolResult(result);
|
|
||||||
const childSessionId = readChildSessionId(result);
|
const childSessionId = readChildSessionId(result);
|
||||||
return {
|
return {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
@@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
|
|||||||
argRecord.assignment,
|
argRecord.assignment,
|
||||||
),
|
),
|
||||||
...(childSessionId ? { childSessionId } : {}),
|
...(childSessionId ? { childSessionId } : {}),
|
||||||
log: resultText?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
...item,
|
...item,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error,
|
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,
|
turnId,
|
||||||
|
|||||||
@@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z
|
|||||||
])
|
])
|
||||||
.nullable();
|
.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 {
|
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
||||||
const text = readOutputText(value);
|
const text = readOutputText(value);
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
|||||||
function deriveOpencodeTaskDetail(
|
function deriveOpencodeTaskDetail(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
output: unknown,
|
output: unknown,
|
||||||
error: unknown,
|
|
||||||
metadata: Record<string, unknown> | undefined,
|
metadata: Record<string, unknown> | undefined,
|
||||||
): ToolCallDetail | null {
|
): ToolCallDetail | null {
|
||||||
if (!isRecord(input)) {
|
if (!isRecord(input)) {
|
||||||
@@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
|
|
||||||
const childSessionId =
|
const childSessionId =
|
||||||
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
|
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
|
||||||
return {
|
return {
|
||||||
@@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail(
|
|||||||
...(subAgentType ? { subAgentType } : {}),
|
...(subAgentType ? { subAgentType } : {}),
|
||||||
...(description ? { description } : {}),
|
...(description ? { description } : {}),
|
||||||
...(childSessionId ? { childSessionId } : {}),
|
...(childSessionId ? { childSessionId } : {}),
|
||||||
log,
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail(
|
|||||||
toolName: string,
|
toolName: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
output: unknown,
|
output: unknown,
|
||||||
error: unknown = null,
|
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): ToolCallDetail {
|
): ToolCallDetail {
|
||||||
if (toolName.trim().toLowerCase() === "task") {
|
if (toolName.trim().toLowerCase() === "task") {
|
||||||
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
|
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
|
||||||
if (taskDetail) {
|
if (taskDetail) {
|
||||||
return taskDetail;
|
return taskDetail;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps completed task calls with final output log to sub_agent detail", () => {
|
it("maps completed task calls to sub_agent detail", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapOpencodeToolCall({
|
mapOpencodeToolCall({
|
||||||
toolName: "task",
|
toolName: "task",
|
||||||
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Explore agent-tools codebase",
|
description: "Explore agent-tools codebase",
|
||||||
log: "Found the CLI entrypoint and provider registry.",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
|
|||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Explore current directory",
|
description: "Explore current directory",
|
||||||
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
||||||
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps aborted task calls with preserved error log to sub_agent detail", () => {
|
it("maps aborted task calls to sub_agent detail", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapOpencodeToolCall({
|
mapOpencodeToolCall({
|
||||||
toolName: "task",
|
toolName: "task",
|
||||||
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "explore",
|
subAgentType: "explore",
|
||||||
description: "Explore agent-tools codebase",
|
description: "Explore agent-tools codebase",
|
||||||
log: "Tool execution aborted",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
|
|||||||
const error = raw.error ?? null;
|
const error = raw.error ?? null;
|
||||||
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
||||||
const status = normalizeToolCallStatus(rawStatus, error, output);
|
const status = normalizeToolCallStatus(rawStatus, error, output);
|
||||||
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
|
const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata);
|
||||||
|
|
||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,4 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
|
|
||||||
import type { PiChildSessionFile, PiExtension } from "../contract.js";
|
import type { PiChildSessionFile, PiExtension } from "../contract.js";
|
||||||
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
|
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
|
||||||
|
|
||||||
@@ -99,7 +98,8 @@ export const edxethPiSubagents: PiExtension = {
|
|||||||
detail: {
|
detail: {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
description,
|
description,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
},
|
},
|
||||||
subagents,
|
subagents,
|
||||||
childSessions,
|
childSessions,
|
||||||
|
|||||||
+12
@@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => {
|
|||||||
.filter((event) => event.event.type === "upsert")
|
.filter((event) => event.event.type === "upsert")
|
||||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||||
).toEqual(["running", "completed"]);
|
).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 () => {
|
test("correlates a background notification and result collection", async () => {
|
||||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||||
|
|||||||
+18
-3
@@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = {
|
|||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
subAgentType: title,
|
subAgentType: title,
|
||||||
description,
|
description,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
if (call.status === "running")
|
if (call.status === "running")
|
||||||
return {
|
return {
|
||||||
@@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = {
|
|||||||
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
|
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
|
||||||
pendingFilesByAgent.delete(details.data.agentId);
|
pendingFilesByAgent.delete(details.data.agentId);
|
||||||
const childSessions = childSession(call.callId, file);
|
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 {
|
return {
|
||||||
detail,
|
detail,
|
||||||
subagents: [
|
subagents: [
|
||||||
@@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
toolCallId: call.callId,
|
toolCallId: call.callId,
|
||||||
status: status(details.data.status),
|
status: childStatus,
|
||||||
},
|
},
|
||||||
|
...(answer
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "timeline" as const,
|
||||||
|
id: call.callId,
|
||||||
|
item: { type: "assistant_message" as const, text: answer },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
childSessions,
|
childSessions,
|
||||||
};
|
};
|
||||||
@@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = {
|
|||||||
const detail = {
|
const detail = {
|
||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
description: details.success ? details.data.description : undefined,
|
description: details.success ? details.data.description : undefined,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
if (call.status === "running" || !details.success) return { detail };
|
if (call.status === "running" || !details.success) return { detail };
|
||||||
const file = details.data.transcriptPath;
|
const file = details.data.transcriptPath;
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "scout",
|
subAgentType: "scout",
|
||||||
description: "Inspect files",
|
description: "Inspect files",
|
||||||
log: "Found two files",
|
log: "",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => {
|
|||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "reviewer",
|
subAgentType: "reviewer",
|
||||||
description: "Review the Pi mapper change",
|
description: "Review the Pi mapper change",
|
||||||
log: "The mapper change preserves provider status.",
|
log: "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export const piSubagents: PiExtension = {
|
|||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
subAgentType: args.data.agent,
|
subAgentType: args.data.agent,
|
||||||
description: args.data.task,
|
description: args.data.task,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
const base = {
|
const base = {
|
||||||
type: "upsert" as const,
|
type: "upsert" as const,
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export async function verifySubagentFixture(
|
|||||||
try {
|
try {
|
||||||
await session.startTurn("Delegate work");
|
await session.startTurn("Delegate work");
|
||||||
for (const event of fixture.events) pi.latestSession().emit(event);
|
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();
|
pi.latestSession().finishTurn();
|
||||||
const replay: AgentStreamEvent[] = [];
|
const replay: AgentStreamEvent[] = [];
|
||||||
for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event);
|
for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event);
|
||||||
|
|||||||
+19
-2
@@ -36,12 +36,17 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
|||||||
});
|
});
|
||||||
expect(mapping?.subagents).toEqual([
|
expect(mapping?.subagents).toEqual([
|
||||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
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({
|
expect(mapping?.detail).toEqual({
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Explore",
|
subAgentType: "Explore",
|
||||||
description: "Inspect",
|
description: "Inspect",
|
||||||
log: "Wrapped up (turn limit)",
|
log: "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||||
@@ -53,6 +58,18 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
|||||||
.filter((event) => event.event.type === "upsert")
|
.filter((event) => event.event.type === "upsert")
|
||||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||||
).toEqual(["running", "completed"]);
|
).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 () => {
|
test("uses a structured notification to complete a background child", async () => {
|
||||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||||
@@ -147,7 +164,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
|||||||
expect(mapping?.detail).toEqual({
|
expect(mapping?.detail).toEqual({
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
log: "Wrapped up (turn limit)",
|
log: "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-3
@@ -57,7 +57,8 @@ export const tintinwebPiSubagents: PiExtension = {
|
|||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
subAgentType: title,
|
subAgentType: title,
|
||||||
description,
|
description,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
if (call.status === "running")
|
if (call.status === "running")
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +85,10 @@ export const tintinwebPiSubagents: PiExtension = {
|
|||||||
const file = outputFileFromToolResult(call.result);
|
const file = outputFileFromToolResult(call.result);
|
||||||
const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : [];
|
const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : [];
|
||||||
if (file) readSessions.add(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 {
|
return {
|
||||||
detail,
|
detail,
|
||||||
subagents: [
|
subagents: [
|
||||||
@@ -93,8 +98,17 @@ export const tintinwebPiSubagents: PiExtension = {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
toolCallId: call.callId,
|
toolCallId: call.callId,
|
||||||
status: status(details.data.status),
|
status: childStatus,
|
||||||
},
|
},
|
||||||
|
...(answer
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "timeline" as const,
|
||||||
|
id: call.callId,
|
||||||
|
item: { type: "assistant_message" as const, text: answer },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
childSessions,
|
childSessions,
|
||||||
};
|
};
|
||||||
@@ -110,7 +124,8 @@ export const tintinwebPiSubagents: PiExtension = {
|
|||||||
const detail = {
|
const detail = {
|
||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
description: details.success ? details.data.description : undefined,
|
description: details.success ? details.data.description : undefined,
|
||||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||||
|
log: "",
|
||||||
};
|
};
|
||||||
if (call.status === "running" || !details.success) return { detail };
|
if (call.status === "running" || !details.success) return { detail };
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-23
@@ -1,6 +1,3 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -10,7 +7,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
|||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
|
||||||
test("background cards retain child actions after the parent finishes (#6253)", async () => {
|
test("background children keep their actions after the parent finishes (#6253)", async () => {
|
||||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
const daemon = await createTestPaseoDaemon({
|
const daemon = await createTestPaseoDaemon({
|
||||||
@@ -62,22 +59,6 @@ test("background cards retain child actions after the parent finishes (#6253)",
|
|||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "projected",
|
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(
|
expect(
|
||||||
atFinish.entries.filter(
|
atFinish.entries.filter(
|
||||||
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
||||||
@@ -94,9 +75,6 @@ test("background cards retain child actions after the parent finishes (#6253)",
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
for (const card of cards) {
|
|
||||||
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user