diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index f205d78924..3d6af249bf 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -21,10 +21,16 @@ The invariants are: > agent establishes the daemon's current tail in one complete-turn request, with older history reachable > through backward pagination. -Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced -to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events. -Provider history hydration applies the same rule so reopening an agent cannot restore an oversized -tool payload. +Every item is bounded before it enters either delivery path, because a timeline page is one +WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get +64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a +`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up +to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker +element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The +budget does not bound wide objects or multi-byte text. The same +bounded item is used for runtime timeline rows and live stream events. Provider history hydration +applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it +again to assistant and reasoning rows the projection merges from many chunks. ## Presence is not delivery diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a10bd7b0c6..5e08f944df 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -64,7 +64,7 @@ function resolveShouldFill( ): boolean { if (!fillAvailableHeight) return false; const t = detail?.type; - return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; + return t === "shell" || t === "edit" || t === "write" || t === "read"; } function useDetailStyles( @@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -function resolveSubAgentFallbackHeader( - subAgentType: string | null | undefined, - description: string | null | undefined, - fallbackText: string, -): string { - if (subAgentType && description) { - return `${subAgentType}: ${description}`; - } - return subAgentType ?? description ?? fallbackText; -} - -interface SubAgentDetailProps { - log: string; - childSessionId: string | null | undefined; - subAgentType: string | null | undefined; - description: string | null | undefined; - ds: DetailStyles; -} - -interface SubAgentActivityRow { - index: number; - toolName: string; - summary?: string; -} - -interface ParsedSubAgentLog { - actions: SubAgentActivityRow[]; - remainingLog: string; -} - -function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null { - const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/); - if (!match) { - return null; - } - const toolName = match[1]?.trim(); - if (!toolName) { - return null; - } - const summary = match[2]?.trim(); - return { - index, - toolName, - ...(summary ? { summary } : {}), - }; -} - -function parseSubAgentLog(log: string): ParsedSubAgentLog { - const actions: SubAgentActivityRow[] = []; - const remainingLines: string[] = []; - for (const line of log.replace(/^\n+/, "").split("\n")) { - const normalizedLine = line.trim(); - if (!normalizedLine) { - continue; - } - const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1); - if (parsedAction) { - actions.push(parsedAction); - } else { - remainingLines.push(line); - } - } - return { - actions, - remainingLog: remainingLines.join("\n").replace(/^\n+/, ""), - }; -} - -function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) { - return ( - - - {formatSubAgentToolName(action.toolName)} - - {action.summary ? ( - - {action.summary} - - ) : null} - - ); -} - -function formatSubAgentToolName(toolName: string): string { - const trimmed = toolName.trim(); - if (!trimmed) { - return toolName; - } - return trimmed - .replace(/[._-]+/g, " ") - .split(" ") - .filter((segment) => segment.length > 0) - .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) - .join(" "); -} - -function SubAgentLogText({ - activityLog, - fallbackHeader, - hasActions, -}: { - activityLog: string; - fallbackHeader: string; - hasActions: boolean; -}) { - if (activityLog.length > 0) { - return ( - - {activityLog} - - ); - } - if (!hasActions) { - return ( - - {fallbackHeader} - - ); - } - return null; -} - -function SubAgentDetailSection({ - log, - childSessionId, - subAgentType, - description, - ds, -}: SubAgentDetailProps) { - const { t } = useTranslation(); - const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); - const fallbackHeader = resolveSubAgentFallbackHeader( - subAgentType, - description, - t("toolCallDetails.subAgentActivity"), - ); - const hasActions = actions.length > 0; - return ( - - - - - - {childSessionId ? ( - - session {childSessionId} - - ) : null} - {hasActions ? ( - - {actions.map((action) => ( - - ))} - - ) : null} - - - - - - - ); -} - interface EditDetailProps { diffLines: DiffLine[] | undefined; ds: DetailStyles; @@ -636,19 +458,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") return []; - if (detail.type === "sub_agent") { - return [ - , - ]; - } + if (detail.type === "worktree_setup" || detail.type === "sub_agent") return []; if (detail.type === "edit") { return []; } @@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => { shellPrompt: { color: theme.colors.foregroundMuted, }, - subAgentSessionText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - marginBottom: theme.spacing[2], - }, - subAgentActions: { - gap: theme.spacing[1], - marginBottom: theme.spacing[2], - }, - subAgentActionRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - subAgentActionTool: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - }, - subAgentActionSummary: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 18ba3f041e..b73b2c4fec 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -832,7 +832,6 @@ describe("translation resources", () => { expect(en.common.placeholders.search).toBe("Search..."); expect(en.common.empty.noResults).toBe("No results found"); expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search."); - expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity"); expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview"); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 99d5a11701..7c9e13cce1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2013,7 +2013,6 @@ export const ar: TranslationResources = { toolCallDetails: { error: "خطأ", empty: "لا توجد تفاصيل إضافية متاحة", - subAgentActivity: "نشاط الوكيل الفرعي", input: "مدخل", output: "الإخراج", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 636fe42d5a..7022dc9a3d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2038,7 +2038,6 @@ export const en = { toolCallDetails: { error: "Error", empty: "No additional details available", - subAgentActivity: "Sub-agent activity", input: "Input", output: "Output", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index abd5320fe9..1c2c07ade0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2061,7 +2061,6 @@ export const es: TranslationResources = { toolCallDetails: { error: "Error", empty: "No hay detalles adicionales disponibles", - subAgentActivity: "Actividad de subagente", input: "Aporte", output: "Producción", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 848666b2bb..bf1c3b6f5e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2060,7 +2060,6 @@ export const fr: TranslationResources = { toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", - subAgentActivity: "Activité du sous-agent", input: "Entrée", output: "Sortie", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index eb6423913e..5147134716 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2033,7 +2033,6 @@ export const ja: TranslationResources = { toolCallDetails: { error: "エラー", empty: "追加の詳細はありません", - subAgentActivity: "サブエージェントアクティビティ", input: "入力", output: "出力", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 54605df4a6..632bd1eeed 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2023,7 +2023,6 @@ export const ko: TranslationResources = { toolCallDetails: { error: "오류", empty: "추가 세부 정보가 없습니다", - subAgentActivity: "서브에이전트 활동", input: "입력", output: "출력", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ecedcb5c9e..a80ded2f45 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = { toolCallDetails: { error: "Erro", empty: "Nenhum detalhe adicional disponível", - subAgentActivity: "Atividade do subagente", input: "Entrada", output: "Saída", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d9c7afdedd..d4a23a4770 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2046,7 +2046,6 @@ export const ru: TranslationResources = { toolCallDetails: { error: "Ошибка", empty: "Дополнительные сведения отсутствуют", - subAgentActivity: "Активность субагента", input: "Входные данные", output: "Выходные данные", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e0b5bbcdb4..2ce5f7bb9e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = { toolCallDetails: { error: "错误", empty: "没有可用的更多详情", - subAgentActivity: "Sub-agent 活动", input: "输入", output: "输出", }, diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index e2d983d8cb..b28848af8e 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract, -): boolean { - return Boolean(detail.subAgentType || detail.description || detail.log); -} - function hasMeaningfulPlainTextDetail( detail: Extract, ): boolean { @@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): case "fetch": return hasMeaningfulFetchDetail(detail); case "worktree_setup": - return false; + // A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel. case "sub_agent": - return hasMeaningfulSubAgentDetail(detail); + return false; case "plain_text": return hasMeaningfulPlainTextDetail(detail); case "plan": @@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: { status: "executing" | "running" | "completed" | "failed" | "canceled"; error: unknown; }): boolean { - if (params.detail?.type === "worktree_setup") return false; + if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false; const isRunning = params.status === "running" || params.status === "executing"; return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail); } diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index c7fffe8ce9..ca04655430 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -288,7 +288,7 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + log?: string; actions?: Array<{ index: number; toolName: string; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 34272bce48..dc63d56b75 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -641,7 +641,9 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discri subAgentType: z.string().optional(), description: z.string().optional(), childSessionId: z.string().optional(), - log: z.string(), + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a + // launch marker; apps before v0.14.1 required `log`, so daemons still send it empty. + log: z.string().optional(), // Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still // emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those // clients are no longer in the field. diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d6b2bc02ff..abc89b06aa 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -200,8 +200,8 @@ describe("wire schema compatibility", () => { }); }); - test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { - const parsed = LegacySubAgentToolCallSchema.parse({ + test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => { + const card = { type: "tool_call", callId: "call-sub-agent-1", name: "Task", @@ -212,12 +212,26 @@ describe("wire schema compatibility", () => { subAgentType: "Explore", description: "Inspect repository structure", childSessionId: "child-session-1", - log: "[Read] README.md", + log: "", actions: [], }, - }); + } as const; - expect(parsed.detail.actions).toEqual([]); + const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card))); + expect(parsed.detail).toMatchObject({ log: "", actions: [] }); + const { log: _log, ...withoutLog } = card.detail; + expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe( + false, + ); + expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({ + detail: { type: "sub_agent", description: "Inspect repository structure" }, + }); + expect( + AgentTimelineItemPayloadSchema.parse({ + ...card, + detail: { ...card.detail, log: "[Read] README.md" }, + }), + ).toMatchObject({ detail: { log: "[Read] README.md" } }); }); test("old clients parse agent snapshots with rewind capabilities", () => { diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index c98bbae54d..4e9ce49528 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -247,7 +247,7 @@ second line'`, ]); }); - it("keeps nested sub-agent logs beside the sub-agent that produced them", () => { + it("lists each sub-agent as its launch marker", () => { const timeline: AgentTimelineItem[] = [ { type: "assistant_message", text: "Before first child." }, toolCallItem({ @@ -257,7 +257,7 @@ second line'`, type: "sub_agent", subAgentType: "Child one", description: "First investigation", - log: "[Assistant] First child result.", + log: "", }, }), { type: "assistant_message", text: "Between children." }, @@ -268,7 +268,7 @@ second line'`, type: "sub_agent", subAgentType: "Child two", description: "Second investigation", - log: "[Assistant] Second child result.", + log: "", }, }), { type: "assistant_message", text: "After second child." }, @@ -279,10 +279,8 @@ second line'`, expect(result.split("\n")).toEqual([ "[Assistant] Before first child.", "[Child one] First investigation", - "[Assistant] First child result.", "[Assistant] Between children.", "[Child two] Second investigation", - "[Assistant] Second child result.", "[Assistant] After second child.", ]); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 488867c657..2a7cbff5e6 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -164,9 +164,6 @@ function curateProjectedActivityEntries( break; } entries.push(formatToolCallEntry(item, options)); - if (item.detail.type === "sub_agent" && item.detail.log.trim()) { - entries.push(activityEntry(item.detail.log.trim())); - } break; } case "todo": diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 9dc194fa11..c423264869 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager, type AgentManagerEvent } from "./agent-manager.js"; import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { @@ -31,7 +32,6 @@ import type { const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS; const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0); -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, @@ -400,10 +400,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.pushEvent(timelineEvent(toolCall({ status: "completed", output }))); await waitForSessionEventQueue(); @@ -425,10 +422,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output })); @@ -449,10 +443,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]); await harness.manager.hydrateTimelineFromProvider(agentId, { force: true }); @@ -470,10 +461,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.emitLiveTimelineItem( agentId, @@ -495,10 +483,9 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "failed", - error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) }, - }); + const expectedItem = limitAgentTimelineItemContent( + toolCall({ status: "failed", error: { content } }), + ); session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } }))); await waitForSessionEventQueue(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 902fd79ebc..4b3fb5ea46 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -21,6 +21,7 @@ import { } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; @@ -4592,7 +4593,7 @@ test("importProviderSession imports the selected session without listing and pub expect(manager.getTimeline(imported.id)).toEqual([ { type: "user_message", text: "Trace provider imports" }, { type: "assistant_message", text: "Done" }, - { + limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell-result", name: "shell", @@ -4601,10 +4602,10 @@ test("importProviderSession imports the selected session without listing and pub detail: { type: "shell", command: "print output", - output: "x".repeat(64 * 1024), + output: "x".repeat(1024 * 1024), exitCode: 0, }, - }, + }), ]); expect(manager.listProviderSubagents(imported.id)).toEqual([ expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }), @@ -11957,10 +11958,7 @@ test("commits startup notices once on create and after restored history", async exitCode: 0, }, }; - const limitedToolOutput: AgentTimelineItem = { - ...toolOutput, - detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 }, - }; + const limitedToolOutput = limitAgentTimelineItemContent(toolOutput); class NotifyingSession extends TestAgentSession { readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; override async *streamHistory(): AsyncGenerator { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 507cfbceb8..6570392311 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -313,7 +313,10 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1 + // require `log`; every producer sends it empty and carries the same tag. Optional here because + // protocol timeline items, where `log` is optional, flow into this type. + log?: string; actions?: Array<{ index: number; toolName: string; diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index 1ccb78c67a..1461d0aeef 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -1,12 +1,30 @@ +import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; +function serializedBytes(item: AgentTimelineItem): number { + return Buffer.byteLength(JSON.stringify(item), "utf8"); +} + describe("agent timeline content", () => { + test("returns an item within budget unchanged", () => { + const item: AgentTimelineItem = { + type: "tool_call", + callId: "small", + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) }, + }; + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + test.each([false, true])( - "preserves the exact UTF-16 prefix for oversized shell output (failed=%s)", + "cuts oversized shell output and keeps short fields whole (failed=%s)", (failed) => { - const text = "x".repeat(64 * 1024 - 1) + "🙂"; + const text = "x".repeat(1024 * 1024) + "🙂"; const item = limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell", @@ -15,45 +33,116 @@ describe("agent timeline content", () => { detail: { type: "shell", command: "echo large", output: text }, error: failed ? { content: text } : null, }); - expect(item).toEqual({ - type: "tool_call", + expect(item).toMatchObject({ callId: "large-shell", name: "Bash", - status: failed ? "failed" : "completed", - detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) }, - error: failed ? { content: text.slice(0, 64 * 1024) } : null, + detail: { type: "shell", command: "echo large" }, }); + if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape"); + expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); }, ); - test("marks truncated plain-text tool content within the content budget", () => { - const oversizedInput = "x".repeat(64 * 1024 + 1); - + test("replaces an oversized data URL in an unknown tool result with a marker", () => { + const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; const item = limitAgentTimelineItemContent({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: oversizedInput, - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url }], note: "captured" }, }, }); - - expect(limitAgentTimelineItemContent(item)).toEqual(item); expect(item).toEqual({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]", - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" }, }, }); }); + + test("gives non-tool items a 256 KiB budget", () => { + const text = "a".repeat(10 * 1024 * 1024); + const item = limitAgentTimelineItemContent({ type: "assistant_message", text }); + if (item.type !== "assistant_message") throw new Error("shape"); + expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/); + expect(item.text.length).toBeGreaterThan(200 * 1024); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("bounds an item whose structure alone exceeds the budget", () => { + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId: "many", + name: "query", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + const output = item.detail.output as unknown[]; + expect(output.length).toBeLessThan(1_000_000); + expect(output.every((value) => value === 1)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + }); + + test("keeps short strings whole and limits idempotently when an item has many strings", () => { + const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId, + name: "mcp__search__files", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { query: "useWorkspaceSomethingVeryDescriptive" }, + output: Array.from( + { length: 10_000 }, + (_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`, + ), + }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + expect(item.callId).toBe(callId); + expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" }); + const output = item.detail.output as string[]; + expect(output.length).toBeLessThan(10_000); + expect(output.every((path) => path.length === 33)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + + test("keeps a long typed array valid against the wire schema", () => { + const item = limitAgentTimelineItemContent({ + type: "todo", + items: Array.from({ length: 20_000 }, (_, index) => ({ + text: `Task ${index}`, + completed: false, + })), + }); + if (item.type !== "todo") throw new Error("shape"); + expect(item.items.length).toBeLessThan(20_000); + expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("is stable on an already-limited item", () => { + const item = limitAgentTimelineItemContent({ + type: "reasoning", + text: "r".repeat(1024 * 1024), + }); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); }); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 003239b283..5117073e14 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -1,9 +1,19 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024; +// One timeline fetch sends its entries in one WebSocket message, so every item must stay small. +// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound +// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text. +const TOOL_CALL_BUDGET = 64 * 1024; +const ITEM_BUDGET = 256 * 1024; +const NODE_COST = 8; +const MARKER_COST = 32; +// Ids, names, paths and queries stay whole, so an item keeps its identity across passes. +const SHORT_STRING = 1024; +const MAX_DEPTH = 64; + export function assertPluginTimelineDataSize(data: JsonValue): void { const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8"); if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) { @@ -17,66 +27,119 @@ function retainTextPrefix(text: string, length: number): string { return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le"); } -function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - item.status !== "failed" || - typeof item.error !== "object" || - item.error === null || - !("content" in item.error) || - typeof item.error.content !== "string" || - item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - return { - ...item, - error: { - ...item.error, - content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; +function formatSize(units: number): string { + if (units < 1024) return `${units} B`; + if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`; + return `${(units / (1024 * 1024)).toFixed(1)} MB`; } -function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "plain_text" || - typeof item.detail.text !== "string" || - item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - const notice = "\n\n[Content truncated]"; - return { - ...item, - detail: { - ...item.detail, - text: - retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, - }, - }; +function truncatedMarker(units: number): string { + return `…[truncated ${formatSize(units)}]`; } +interface ItemSize { + nodeCost: number; + stringLengths: number[]; + tooDeep: boolean; +} + +function measure(value: unknown, depth: number, size: ItemSize): void { + size.nodeCost += NODE_COST; + if (depth > MAX_DEPTH) { + size.tooDeep = true; + } else if (typeof value === "string") { + size.stringLengths.push(value.length); + } else if (Array.isArray(value)) { + for (const element of value) measure(element, depth + 1, size); + } else if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + size.nodeCost += key.length; + measure(child, depth + 1, size); + } + } +} + +/** The largest per-string length that keeps all strings, once cut, within `available`. */ +function findStringCap(lengths: readonly number[], available: number): number { + let low = 0; + let high = 0; + for (const length of lengths) high = Math.max(high, length); + while (low < high) { + const cap = Math.ceil((low + high) / 2); + let total = 0; + for (const length of lengths) total += Math.min(length, cap); + if (total <= available) low = cap; + else high = cap - 1; + } + return low; +} + +interface CutState { + stringCap: number; + remaining: number; +} + +function cutString(text: string, cap: number): string { + if (text.length <= cap) return text; + // A partial data URL is useless to every reader, so drop it whole. + if (text.startsWith("data:")) return truncatedMarker(text.length); + return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap); +} + +function cut(value: unknown, depth: number, state: CutState): unknown { + state.remaining -= NODE_COST; + if (depth > MAX_DEPTH) return "…[truncated]"; + if (typeof value === "string") { + const text = cutString(value, state.stringCap); + state.remaining -= text.length; + return text; + } + if (Array.isArray(value)) { + let changed = false; + const output: unknown[] = []; + for (const element of value) { + const remainingBefore = state.remaining; + const next = cut(element, depth + 1, state); + if (state.remaining < 0) { + // Drop this and later elements. A marker element would break typed arrays. + state.remaining = remainingBefore; + changed = true; + break; + } + changed ||= next !== element; + output.push(next); + } + return changed ? output : value; + } + if (typeof value === "object" && value !== null) { + let changed = false; + const output: Record = {}; + for (const [key, child] of Object.entries(value)) { + state.remaining -= key.length; + const next = cut(child, depth + 1, state); + changed ||= next !== child; + output[key] = next; + } + return changed ? output : value; + } + return value; +} + +/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem { - item = limitFailedShellError(item); - item = limitPlainText(item); - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - typeof item.detail.output !== "string" - ) { - return item; - } - if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) { - return item; - } - return { - ...item, - detail: { - ...item.detail, - output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; + const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET; + const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false }; + measure(item, 0, size); + let stringTotal = 0; + for (const length of size.stringLengths) stringTotal += length; + if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item; + + // Strings get what structure leaves, but at least half the budget, less a margin for JSON + // escaping. Arrays drop the elements that do not fit, which bounds items whose short strings + // or structure alone are too big. + const markerCost = + MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length; + const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost; + const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available)); + return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem; } diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 5d3c836e65..9ec4df4d55 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -181,6 +181,20 @@ describe("projected timeline retention", () => { }, ]); }); + + it("serves a merged assistant message within the item budget", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + const chunk = "a".repeat(200 * 1024); + for (let index = 0; index < 10; index++) { + store.append("agent", { type: "assistant_message", messageId: "m", text: chunk }); + } + const [row] = store.fetch("agent", { limit: 1 }).rows; + expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 }); + expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" }); + expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024); + expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length); + }); }); describe("projected sequence ownership", () => { diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 49b5013fd4..4c3b9c5c3a 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -120,7 +121,12 @@ export class InMemoryAgentTimelineStore { hasNewer: page.hasNewer, startSeq: page.startSeq, endSeq: page.endSeq, - rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + // Stored rows are already within budget; this bounds assistant and reasoning merges. + rows: page.entries.map((entry) => + Object.assign({ seq: entry.seqEnd }, entry, { + item: limitAgentTimelineItemContent(entry.item), + }), + ), }; } diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index dc71c0f5c6..5c2ca55174 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => { const [row] = subagents.fetchTimeline("parent-a", "child-1").rows; expect(row?.item).toMatchObject({ type: "tool_call", - detail: { type: "shell", output: "x".repeat(64 * 1024) }, + detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) }, }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 8530e76844..8aa15581ae 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -466,13 +466,13 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }); test.each(["before", "after"])( - "keeps one logged Task card when child tools start %s the parent finishes", + "keeps one Task card when child tools start %s the parent finishes", async (timing) => { // A child can stream across the main session's turn boundary. Claude opens the next // main-session turn with system/init; bare child frames do not open a turn. @@ -605,7 +605,7 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }, diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 9fc82d6de8..4728767307 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -737,7 +737,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); }, 120_000); - test("limits retained child shell output to the timeline's existing content limit", async () => { + test("limits retained child shell output to the timeline item budget", async () => { const output = "x".repeat(128 * 1024); writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -782,7 +782,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => { type: "timeline", id: TOOL_USE_ID, item: expect.objectContaining({ - detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }), + detail: expect.objectContaining({ + type: "shell", + output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/), + }), }), }), ]); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index 84293afa6a..f219baa1b1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { queryFactory.mockReset(); }); - test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { + test("labels the Task card from its input and preserves callId lifecycle collapse", async () => { const session = await new ClaudeAgentClient({ logger, queryFactory, @@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.subAgentType).toBe("Explore"); expect(latest.detail.description).toBe("Inspect repository structure"); - expect(latest.detail.log).toContain("[Read] README.md"); - expect(latest.detail.log).toContain("[Edit] src/index.ts"); - expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING"); + expect(latest.detail.log).toBe(""); const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({ seq: index + 1, @@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { .map((event) => event.item) .at(-1); - expect(latestSubAgentUpdate?.detail).toMatchObject({ - type: "sub_agent", - log: expect.stringContaining("[Read] README.md"), - }); + expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" }); }); test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => { @@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { ).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" }); }); - test("tails sub-agent actions instead of dropping latest entries at cap", async () => { + test("keeps the Task card the same size while the child runs", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); const session = await new ClaudeAgentClient({ @@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { const events = await collectUntilTerminal(streamSession(session, "delegate work")); await session.close(); - const timelineToolCalls = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "tool_call", + const runningCards = events + .flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === "task-tail-1" && + event.item.status === "running" && + event.item.detail.type === "sub_agent" + ? [event.item] + : [], ) - .map((event) => event.item) - .filter((item) => item.callId === "task-tail-1"); - const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent"); - const latest = subAgentUpdates[subAgentUpdates.length - 1]; - expect(latest).toBeDefined(); - if (!latest || latest.detail.type !== "sub_agent") { - throw new Error("expected sub_agent detail"); - } + .map((item) => JSON.stringify(item)); + expect(runningCards).toHaveLength(1); - expect(latest.detail.log).not.toContain("[Read] file-1.md"); - expect(latest.detail.log).not.toContain("[Read] file-5.md"); - expect(latest.detail.log).toContain("[Read] file-6.md"); - expect(latest.detail.log).toContain("[Read] file-205.md"); + const childToolCalls = events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "task-tail-1" && + event.event.item.type === "tool_call", + ); + expect(childToolCalls).toHaveLength(205); }); test("keeps a long main-thread tool call out of the subagents track", async () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 824a93f3da..c675b1ed62 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession { /** * The Task card in the parent transcript, built from the declaration. * - * The sidechain tracker also emits this card, enriched with the child's action log, and the two + * The sidechain tracker also emits this card when it first sees the child, and the two * collapse on the shared tool-call id. Emitting it at declaration matters because a * backgrounded subagent produces no sidechain frames at all — verified on the wire — so the * tracker never runs for one and its card would otherwise stay an unlabeled "Task". @@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail( type: "sub_agent", ...(facts.title ? { subAgentType: facts.title } : {}), ...(facts.description ? { description: facts.description } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index a17c1cd58c..427f279bea 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; describe("ClaudeSidechainTracker", () => { - it("retains a background child's actions across the parent finishing", () => { + it("keeps a background child's card across the parent finishing", () => { const tracker = new ClaudeSidechainTracker({ getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }), isDescriptorOwnedElsewhere: () => true, @@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => { parent_tool_use_id: "task-1", message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] }, }) as unknown as SDKMessage; - tracker.handleMessage(action("ls", "ls -1"), "task-1"); + const parentCards = (events: ReturnType) => + events.filter((event) => event.type === "timeline"); + const childCallIds = (events: ReturnType) => + events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item.callId] + : [], + ); + + expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1); expect(tracker.finishAll("completed")).toEqual([]); - const events = tracker.handleMessage(action("pwd", "pwd"), "task-1"); - expect(events).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }), - }), - }), - ); + const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1"); + expect(parentCards(afterFinish)).toEqual([]); + expect(childCallIds(afterFinish)).toEqual(["pwd"]); + tracker.delete("task-1"); const next = tracker.handleMessage(action("next", "ls"), "task-1"); - expect(next).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls" }), - }), - }), - ); + expect(parentCards(next)).toHaveLength(1); + expect(childCallIds(next)).toEqual(["next"]); + }); + + it("shows a streamed child tool call's input once the assistant message brings it", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + content: [ + { type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } }, + ], + }, + }, + ] as unknown as SDKMessage[]; + + const childCards = frames + .flatMap((frame) => tracker.handleMessage(frame, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + + expect(childCards.at(-1)).toMatchObject({ + callId: "bash-1", + status: "running", + detail: { type: "shell", command: "echo child" }, + }); }); it("uses Claude's native agent name for the provider subagent title", () => { diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 083b82cea6..5ca79641f9 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -5,7 +5,6 @@ import { mapClaudeFailedToolCall, mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; -import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { isClaudeSubagentHandbackToolName, readClaudeSubagentHandback, @@ -20,21 +19,17 @@ interface ClaudeContentChunk { type ClaudeToolCallItem = Extract; -interface SubAgentActionEntry { - index: number; +interface SubAgentChildCall { toolName: string; input: unknown; - summary?: string; } interface SubAgentActivityState { name?: string; subAgentType?: string; description?: string; - actions: SubAgentActionEntry[]; - actionKeys: string[]; - nextActionIndex: number; - actionIndexByKey: Map; + /** The child's tool calls awaiting results, which carry no input of their own. */ + runningCalls: Map; completedActionKeys: Set; } @@ -44,7 +39,6 @@ interface SubAgentActionCandidate { input: unknown; } -const MAX_SUB_AGENT_LOG_ENTRIES = 200; const MAX_SUB_AGENT_SUMMARY_CHARS = 160; function readTrimmedString(value: unknown): string | undefined { @@ -79,8 +73,7 @@ export class ClaudeSidechainTracker { needsSyntheticParentToolCard?: (toolUseId: string) => boolean; /** * The parent's tool call once Claude has answered it. A background child streams its frames - * after that answer, so its card keeps the settled status and output and gains the child's - * action log. + * after that answer, so its card keeps the settled status and output. */ getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined; }) { @@ -91,28 +84,27 @@ export class ClaudeSidechainTracker { } handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] { + const existingState = this.activeSidechains.get(parentToolUseId); const state = - this.activeSidechains.get(parentToolUseId) ?? + existingState ?? ({ - actions: [], - actionKeys: [], - nextActionIndex: 1, - actionIndexByKey: new Map(), + runningCalls: new Map(), completedActionKeys: new Set(), } satisfies SubAgentActivityState); this.activeSidechains.set(parentToolUseId, state); - const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); + // The parent card is a launch marker: it changes only when the child is first seen or the + // task input names it differently, never with the child's activity. + const cardUpdated = + this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState; const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; - let actionUpdated = false; for (const action of actionCandidates) { if (state.completedActionKeys.has(action.key)) continue; - if (this.appendSubAgentAction(state, action)) { - actionUpdated = true; + if (this.trackChildCall(state, action)) { const toolCall = mapClaudeRunningToolCall({ name: action.toolName, callId: action.key, @@ -125,7 +117,7 @@ export class ClaudeSidechainTracker { } } - if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) { + if (!cardUpdated && childTimelineItems.length === 0) { return []; } @@ -145,11 +137,8 @@ export class ClaudeSidechainTracker { type: "sub_agent", ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.description ? { description: state.description } : {}), - log: state.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"), + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; @@ -170,18 +159,19 @@ export class ClaudeSidechainTracker { }, ]; - const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId) - ? [ - { - type: "timeline", - item: { - ...toolCall, - detail, + const parentCard: AgentStreamEvent[] = + cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId) + ? [ + { + type: "timeline", + item: { + ...toolCall, + detail, + }, + provider: "claude", }, - provider: "claude", - }, - ] - : []; + ] + : []; return [ ...descriptorEvents, @@ -303,8 +293,7 @@ export class ClaudeSidechainTracker { if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue; const callId = readTrimmedString(block.tool_use_id); if (!callId || state.completedActionKeys.has(callId)) continue; - const actionIndex = state.actionIndexByKey.get(callId); - const action = actionIndex === undefined ? undefined : state.actions[actionIndex]; + const action = state.runningCalls.get(callId); const toolName = action?.toolName ?? readTrimmedString(block.tool_name); if (!toolName) continue; const params = { @@ -317,6 +306,7 @@ export class ClaudeSidechainTracker { ? mapClaudeFailedToolCall({ ...params, error: block }) : mapClaudeCompletedToolCall(params); if (toolCall) { + state.runningCalls.delete(callId); state.completedActionKeys.add(callId); items.push(toolCall); } @@ -412,13 +402,12 @@ export class ClaudeSidechainTracker { const key = readTrimmedString(block.id) ?? `stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`; - return [ - { - key, - toolName: block.name, - input: block.input ?? null, - }, - ]; + // A streamed call starts with `{}` until its input arrives, so an empty input is not final. + const input = + block.input && typeof block.input === "object" && Object.keys(block.input).length > 0 + ? block.input + : null; + return [{ key, toolName: block.name, input }]; } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { @@ -448,84 +437,20 @@ export class ClaudeSidechainTracker { return []; } - private appendSubAgentAction( + /** Returns whether the call is new or has gained its input, so its running card changes. */ + private trackChildCall( state: SubAgentActivityState, candidate: SubAgentActionCandidate, ): boolean { - const normalizedToolName = readTrimmedString(candidate.toolName); - if (!normalizedToolName) { + const toolName = readTrimmedString(candidate.toolName); + if (!toolName) { return false; } - - const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input); - const existingIndex = state.actionIndexByKey.get(candidate.key); - - if (existingIndex !== undefined) { - const existing = state.actions[existingIndex]; - if (!existing) { - return false; - } - const nextSummary = existing.summary ?? summary; - if (existing.toolName === normalizedToolName && existing.summary === nextSummary) { - return false; - } - state.actions[existingIndex] = { - ...existing, - toolName: normalizedToolName, - input: existing.input ?? candidate.input, - ...(nextSummary ? { summary: nextSummary } : {}), - }; - return true; + const existing = state.runningCalls.get(candidate.key); + if (existing && (existing.input !== null || candidate.input === null)) { + return false; } - - state.actions.push({ - index: state.nextActionIndex, - toolName: normalizedToolName, - input: candidate.input, - ...(summary ? { summary } : {}), - }); - state.nextActionIndex += 1; - state.actionKeys.push(candidate.key); - this.trimSubAgentTail(state); - this.rebuildSubAgentActionIndex(state); + state.runningCalls.set(candidate.key, { toolName, input: candidate.input }); return true; } - - private trimSubAgentTail(state: SubAgentActivityState): void { - while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { - state.actions.shift(); - const removedKey = state.actionKeys.shift(); - if (removedKey) state.completedActionKeys.delete(removedKey); - } - } - - private rebuildSubAgentActionIndex(state: SubAgentActivityState): void { - state.actionIndexByKey.clear(); - for (let index = 0; index < state.actionKeys.length; index += 1) { - const key = state.actionKeys[index]; - if (key) { - state.actionIndexByKey.set(key, index); - } - } - } - - private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined { - const runningToolCall = mapClaudeRunningToolCall({ - name: toolName, - callId: `sub-agent-summary-${toolName}`, - input, - output: null, - }); - if (!runningToolCall) { - return undefined; - } - const display = buildToolCallDisplayModel({ - name: runningToolCall.name, - status: runningToolCall.status, - error: runningToolCall.error, - detail: runningToolCall.detail, - metadata: runningToolCall.metadata, - }); - return this.normalizeSubAgentText(display.summary); - } } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts index 5a6592862c..f564b7bf94 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts @@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response. const completedChildIndex = events.findIndex( (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.status === "completed" && - event.item.detail.type === "sub_agent" && - event.item.detail.log.includes("CHILD_SENTINEL"), + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed", ); + const childAssistantText = events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" + ? [event.event.item.text] + : [], + ) + .join(""); const rootMessageIndexes = events.flatMap((event, index) => event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [], ); @@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response. .map((item) => item.text) .join(""); expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL"); + expect(childAssistantText).toContain("CHILD_SENTINEL"); expect(completedChildIndex).toBeGreaterThanOrEqual(0); expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex); expect(terminalIndexes).toHaveLength(1); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index cb8cb2684b..61a86ccb8f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => { ]); }); - test("folds child-thread Codex activity into the parent sub-agent tool call", async () => { + test("keeps child-thread Codex activity out of the parent sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3562,7 +3562,6 @@ describe("Codex app-server provider", () => { }); const timelineEvents = events.filter((event) => event.type === "timeline"); - expect(timelineEvents).toHaveLength(4); expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true); const finalItem = timelineEvents.at(-1)?.item; expect(finalItem).toMatchObject({ @@ -3574,7 +3573,7 @@ describe("Codex app-server provider", () => { type: "sub_agent", subAgentType: "Sub-agent", description: "Report findings.", - log: "[Assistant] Found the path.", + log: "", actions: [], }, }); @@ -3651,13 +3650,9 @@ describe("Codex app-server provider", () => { id: "settled-child-thread", status: "completed", }); - expect(events.at(-1)).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", - item: { - callId: "call-settled-child", - status: "completed", - detail: { type: "sub_agent", log: "[Assistant] Late trailing output." }, - }, + item: { callId: "call-settled-child", status: "completed" }, }); appServer.completesSubAgentActivity({ @@ -3962,6 +3957,8 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the investigation, then report the result."); @@ -4005,11 +4002,18 @@ describe("Codex app-server provider", () => { type: "tool_call", callId: "spawn-child-early", status: "completed", - detail: { - type: "sub_agent", - log: "[Assistant] Early child findings.", - }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: "child-thread-early", + item: expect.objectContaining({ type: "assistant_message" }), + }), + }), + ); appServer.assertNoErrors(); } finally { await session.close(); @@ -4080,10 +4084,7 @@ describe("Codex app-server provider", () => { expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-stable", status: "canceled", - detail: { - type: "sub_agent", - log: "[Assistant] Still on the same card.", - }, + detail: { type: "sub_agent", log: "" }, }); appServer.assertNoErrors(); } finally { @@ -4163,11 +4164,10 @@ describe("Codex app-server provider", () => { const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); - expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]); + expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]); expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-late-compaction", status: "completed", - detail: { type: "sub_agent", log: "[Compacted]" }, }); appServer.assertNoErrors(); } finally { @@ -4175,7 +4175,7 @@ describe("Codex app-server provider", () => { } }); - test("projects legacy child tools into one stable sub-agent log", async () => { + test("routes legacy child tools to the child timeline", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), @@ -4183,6 +4183,8 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the implementation."); @@ -4225,19 +4227,26 @@ describe("Codex app-server provider", () => { expect(finalToolCall).toMatchObject({ callId: "spawn-legacy-tool-child", status: "completed", - detail: { type: "sub_agent" }, + detail: { type: "sub_agent", log: "" }, }); - if (finalToolCall?.detail.type === "sub_agent") { - expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1); - expect(finalToolCall.detail.log).toContain("[Edit]"); - } + const childToolCalls = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "legacy-tool-child" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual( + new Set(["shell", "edit"]), + ); appServer.assertNoErrors(); } finally { await session.close(); } }); - test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => { + test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -4297,8 +4306,22 @@ describe("Codex app-server provider", () => { expect(beforeParentCompletes.at(-1)).toMatchObject({ callId: "spawn-child-root", status: "running", - detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-message", + text: "Grandchild findings.", + }, + }, + }), + ); await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-root", @@ -4365,13 +4388,20 @@ describe("Codex app-server provider", () => { "child-thread-root", "child-thread-root", ]); - expect(events.at(-1)).toMatchObject({ - type: "timeline", - item: { - callId: "spawn-child-root", - detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") }, - }, - }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-after-wait", + text: "Still nested.", + }, + }, + }), + ); }); test("never treats an unmapped foreign terminal as the root terminal", async () => { @@ -4791,7 +4821,7 @@ describe("Codex app-server provider", () => { expect(events).toHaveLength(eventCountAfterSpawn); }); - test("keeps child context compaction inside the child card", async () => { + test("keeps child context compaction on the child timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -4824,10 +4854,70 @@ describe("Codex app-server provider", () => { (item) => item.type === "tool_call" && item.callId === "spawn-child-compaction", ), ).toBe(true); - expect(timelineItems.at(-1)).toMatchObject({ - type: "tool_call", - detail: { type: "sub_agent", log: "[Compacted]" }, + expect( + events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [], + ), + ).toEqual([ + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "loading" }, + }, + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "completed" }, + }, + ]); + }); + + test("settles a child compaction once when the item completion is missing or duplicated", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + for (const child of ["compacted-child", "turn-ended-child"]) { + await asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: `spawn-${child}`, + kind: "started", + agentThreadId: child, + agentPath: `/root/${child}`, + }, + }); + await asInternals(session).handleNotification("item/started", { + threadId: child, + item: { type: "contextCompaction", id: `${child}-compaction` }, + }); + } + await asInternals(session).handleNotification("thread/compacted", { + threadId: "compacted-child", }); + await asInternals(session).handleNotification("item/completed", { + threadId: "compacted-child", + item: { type: "contextCompaction", id: "compacted-child-compaction" }, + }); + await asInternals(session).handleNotification("turn/completed", { + threadId: "turn-ended-child", + turn: { status: "completed" }, + }); + + const compactions = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [ + `${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`, + ] + : [], + ); + expect(compactions).toEqual([ + "compacted-child loading", + "turn-ended-child loading", + "compacted-child completed", + "turn-ended-child completed", + ]); }); test("keeps the parent sub-agent running when a child command fails during the child turn", async () => { @@ -4862,7 +4952,7 @@ describe("Codex app-server provider", () => { }, }); - expect(events.at(-1)?.item).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({ type: "tool_call", callId: "call-sub-agent-child-command-failure", name: "Sub-agent", @@ -5157,7 +5247,7 @@ describe("Codex app-server provider", () => { ); expect(liveToolCalls.at(-1)).toMatchObject({ status: "running", - detail: { type: "sub_agent", log: "[Assistant] More findings after resume." }, + detail: { type: "sub_agent", log: "" }, }); liveEvents.length = 0; @@ -5170,12 +5260,12 @@ describe("Codex app-server provider", () => { itemId: "legacy-child-message-after-resume", delta: "Legacy findings after resume.", }); - expect(liveEvents.at(-1)).toMatchObject({ + expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", item: { callId: "legacy-spawn-history", status: "running", - detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." }, + detail: { type: "sub_agent", log: "" }, }, }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f7d4f50215..75370f0b5b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -49,10 +49,10 @@ import { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; -import { curateAgentActivity } from "../activity-curator.js"; import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js"; import { mapCodexToolCallEnvelope, @@ -3384,8 +3384,6 @@ interface CodexSubAgentCallState { activityItemIds: Set; pendingCommandOutputDeltas: Map; pendingFileChangeOutputDeltas: Map; - childItemOrder: string[]; - childItems: Map; childThreadIds: Set; } @@ -3487,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession { private compactionTriggerByItemId = new Map(); private pendingRootCompactionItemIds = new Set(); private pendingAnonymousRootCompactions = 0; + // Child threads with a compaction shown as loading. Some Codex builds settle it only through + // `thread/compacted` or the end of the child's turn. + private pendingChildCompactionThreadIds = new Set(); // Codex can report one completed compaction through both channels: // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; @@ -5516,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession { case "patch_apply_completed": this.handlePatchApplyCompletedNotification(parsed, callId); return; + case "context_compacted": + this.settleChildCompaction(parsed.threadId); + return; default: // Aggregate child telemetry is redundant and must not leak into the // root timeline. Concrete legacy tools are projected above for Codex @@ -5692,20 +5696,10 @@ export class CodexAppServerAgentSession implements AgentSession { activityItemIds: new Set(), pendingCommandOutputDeltas: new Map(), pendingFileChangeOutputDeltas: new Map(), - childItemOrder: [], - childItems: new Map(), childThreadIds: new Set(), } satisfies CodexSubAgentCallState); - state.toolCall = { - ...timelineItem, - detail: { - ...timelineItem.detail, - log: - timelineItem.detail.log || - (state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""), - }, - }; + state.toolCall = timelineItem; state.parentCallId ??= parentCallId; state.parentSubagentId ??= parentSubagentId; const activity = readCodexSubAgentActivity(rawItem); @@ -5758,25 +5752,17 @@ export class CodexAppServerAgentSession implements AgentSession { const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { cwd: this.config.cwd ?? null, }); - if ( - activityToolCall?.detail.type === "sub_agent" && - state.toolCall.detail.type === "sub_agent" - ) { - state.toolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - subAgentType: activityToolCall.detail.subAgentType, - }, - }; - } + const subAgentType = + activityToolCall?.detail.type === "sub_agent" + ? activityToolCall.detail.subAgentType + : undefined; let nextStatus: ToolCallTimelineItem["status"] | undefined = "running"; if (activity.kind === "interrupted") { nextStatus = "canceled"; } else if (isTerminalSubAgentStatus(state.toolCall.status)) { nextStatus = undefined; } - this.emitSubAgentActivityUpdate(callId, nextStatus); + this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType }); return true; } @@ -5817,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession { ): boolean { if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "completed", + ) ) { return true; } @@ -5827,17 +5819,6 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void { - const state = this.subAgentCallsByCallId.get(callId); - if (!state) { - return; - } - if (!state.childItems.has(itemId)) { - state.childItemOrder.push(itemId); - } - state.childItems.set(itemId, item); - } - private emitCodexToolTimelineItem( timelineItem: ToolCallTimelineItem, subAgentCallId: string | null, @@ -5847,7 +5828,6 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } - this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); const state = this.subAgentCallsByCallId.get(subAgentCallId); if (state) { const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds; @@ -5861,26 +5841,15 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] { - return state.childItemOrder - .map((itemId) => state.childItems.get(itemId)) - .filter((item): item is AgentTimelineItem => Boolean(item)); - } - private emitSubAgentActivityUpdate( callId: string, status?: ToolCallTimelineItem["status"], - options?: { reopen?: boolean }, + options?: { reopen?: boolean; subAgentType?: string }, ): void { const state = this.subAgentCallsByCallId.get(callId); if (!state || state.toolCall.detail.type !== "sub_agent") { return; } - const childTimeline = this.getSubAgentChildTimeline(state); - const log = - childTimeline.length > 0 - ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) - : ""; let resolvedStatus = status ?? state.toolCall.status; if ( status === "running" && @@ -5889,16 +5858,12 @@ export class CodexAppServerAgentSession implements AgentSession { ) { resolvedStatus = state.toolCall.status; } - for (const childThreadId of state.childThreadIds) { - this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); - } - const baseToolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - log, - }, - }; + const baseToolCall = options?.subAgentType + ? { + ...state.toolCall, + detail: { ...state.toolCall.detail, subAgentType: options.subAgentType }, + } + : state.toolCall; const nextToolCall: ToolCallTimelineItem = resolvedStatus === "failed" ? { @@ -5911,10 +5876,14 @@ export class CodexAppServerAgentSession implements AgentSession { status: resolvedStatus, error: null, }; + // The card is a launch marker: the child's activity streams to its own timeline, so the card + // is re-sent only when its status or label changes. + const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall); state.toolCall = nextToolCall; - if (state.parentCallId && state.parentCallId !== callId) { - this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall); - this.emitSubAgentActivityUpdate(state.parentCallId); + for (const childThreadId of state.childThreadIds) { + this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); + } + if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) { return; } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); @@ -6022,7 +5991,6 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); if (itemId) { - this.upsertSubAgentChildItem(callId, itemId, timelineItem); this.pendingAgentMessages.delete(itemId); this.pendingReasoning.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId); @@ -6033,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession { private handleSubAgentContextCompactionItem( callId: string, + childThreadId: string, item: { id?: string; type?: string; [key: string]: unknown }, status: "loading" | "completed", ): boolean { if (!this.isContextCompactionItem(item)) { return false; } - if (item.id) { - this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status }); + if (status === "loading") { + this.pendingChildCompactionThreadIds.add(childThreadId); + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status }); + } else { + this.settleChildCompaction(childThreadId); } this.emitSubAgentActivityUpdate(callId); return true; } + private settleChildCompaction(childThreadId: string): void { + if (this.pendingChildCompactionThreadIds.delete(childThreadId)) { + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" }); + } + } + private shouldSkipCompletedThreadItem( timelineItem: AgentTimelineItem, normalizedItemType: string | undefined, @@ -6076,11 +6054,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "assistant_message", - messageId: parsed.itemId, - text, - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6114,10 +6087,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "reasoning", - text: prev.join(""), - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6185,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) this.settleChildCompaction(parsed.threadId); let status: ToolCallTimelineItem["status"] = "completed"; if (parsed.status === "failed") { status = "failed"; @@ -6771,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession { const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "loading", + ) ) { return; } @@ -6802,9 +6778,6 @@ export class CodexAppServerAgentSession implements AgentSession { }); if (childSubAgentCallId) { this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); - if (parsed.item.id) { - this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; @@ -6858,9 +6831,6 @@ export class CodexAppServerAgentSession implements AgentSession { if (parsed.threadId) { this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); } - if (itemId) { - this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); return; } diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 64629bc5d9..4e77dc9367 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem( type: "sub_agent", subAgentType: "Sub-agent", ...(item.prompt ? { description: item.prompt } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; @@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem( type: "sub_agent", subAgentType: nativeName, description, + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }, diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index f2f405540b..c8b44c0bd8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -69,7 +69,7 @@ import { type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; @@ -130,7 +130,6 @@ export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; runtime?: OmpRuntime; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -205,7 +204,6 @@ interface OmpAgentSessionOptions { initialState: OmpSessionState; currentModeId?: string | null; logger: Logger; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession { this.logger.debug({ err: error }, "OMP context usage poll failed"); }, }); - this.subagentCardTracker = new OmpSubagentCardTracker({ - scheduler: options.subagentCardScheduler, - }); + this.subagentCardTracker = new OmpSubagentCardTracker(); this.lastKnownThinkingOptionId = normalizeOmpThinkingOption(options.config.thinkingOptionId) ?? this.state.thinkingLevel ?? @@ -2155,8 +2151,7 @@ export class OmpAgentSession implements AgentSession { return mapOmpToolDetail(toolCall, result, { toolCallId, bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName), - mapSubagentDetail: (detail) => - this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail, + mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail), }); } @@ -2246,7 +2241,6 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; @@ -2265,7 +2259,6 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; @@ -2349,7 +2342,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, @@ -2421,7 +2413,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 03c0ebe5ae..6d39c21c2d 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -637,7 +637,7 @@ describe("OMP history mapper", () => { subAgentType: "explore", description: "Inspect files", childSessionId: "/tmp/omp-task/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task/Explore.jsonl", + log: "", }, error: null, }, diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts index 5164097661..6819292d33 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; const PARENT_TOOL_CALL_ID = "task-1"; @@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [ }, ]; -class ManualScheduler implements OmpSubagentCardScheduler { - private currentMs = 0; - private nextId = 1; - private readonly timers = new Map void }>(); - - now(): number { - return this.currentMs; - } - - setTimeout(callback: () => void, delayMs: number) { - const id = this.nextId; - this.nextId += 1; - this.timers.set(id, { dueMs: this.currentMs + delayMs, callback }); - return { token: id }; - } - - clearTimeout(timer: { token: unknown }): void { - if (typeof timer.token === "number") { - this.timers.delete(timer.token); - } - } - - advance(ms: number): void { - this.currentMs += ms; - const dueTimers = [...this.timers.entries()] - .filter(([, timer]) => timer.dueMs <= this.currentMs) - .sort((left, right) => left[1].dueMs - right[1].dueMs); - for (const [id, timer] of dueTimers) { - if (this.timers.delete(id)) { - timer.callback(); - } - } - } -} - describe("OmpSubagentCardTracker", () => { - test("folds lifecycle and progress into one throttled sub-agent detail", () => { - const scheduler = new ManualScheduler(); + test("keeps the card a launch marker while the child runs", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - tracker.handleLifecycle(LIFECYCLE); + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); for (const payload of PROGRESS) { - tracker.handleProgress(payload); + tracker.handleProgress(payload, emit); } expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); - const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, { - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - log: "", - }); - expect(detailBeforeTrailing).toEqual({ - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - childSessionId: SESSION_FILE, - log: [ - "EchoSubagent started", - "[bash] echo subagent-hi", - "[yield]", - "EchoSubagent completed", - ].join("\n"), - }); - - scheduler.advance(500); - - expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); - }); - - test("aggregates batch progress streams into one index-prefixed log", () => { - const scheduler = new ManualScheduler(); - const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - sessionFile: "/tmp/one.jsonl", - parentToolCallId: "task.batch-1", - index: 0, - }); - scheduler.advance(600); - tracker.handleProgress({ - index: 5, - agent: "task", - task: "Run tests", - parentToolCallId: "task.batch-1", - progress: { - id: "Test", - status: "running", - description: "Run tests", - recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }], - }, - sessionFile: "/tmp/two.jsonl", - }); - - expect(emitted).toEqual(["task.batch-1", "task.batch-1"]); expect( - tracker.detailFor("task.batch-1", { + tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", - subAgentType: "batch", - actions: [], + subAgentType: "task", + description: "Task arg description wins", log: "", }), ).toEqual({ type: "sub_agent", - subAgentType: "batch", - description: "Inspect files", - childSessionId: "/tmp/one.jsonl", - log: "[1/6] Explore started\n[6/6] [bash] npm test", - actions: [], + subAgentType: "task", + description: "Task arg description wins", + childSessionId: SESSION_FILE, + log: "", }); }); - test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => { - const scheduler = new ManualScheduler(); + test("re-emits the card when a later event names the child", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit); + tracker.handleProgress(PROGRESS[0], emit); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + childSessionId: SESSION_FILE, + log: "", + }); + + tracker.delete(PARENT_TOOL_CALL_ID); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + log: "", + }); + }); + test("leaves the card alone when a later child of the same task starts", () => { + const emitted: string[] = []; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); + tracker.handleLifecycle( + { + ...LIFECYCLE, + id: "Second", + index: 1, + description: "Second child", + sessionFile: "/s.jsonl", }, + emit, + ); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + description: "Run echo in subagent", + childSessionId: SESSION_FILE, + log: "", }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - parentToolCallId: "task-1", - index: 0, - }); - scheduler.advance(100); - tracker.handleProgress({ - index: 0, - agent: "task", - task: "Inspect files", - parentToolCallId: "task-1", - progress: { - id: "Explore", - status: "running", - recentOutput: ["found target file"], - }, - }); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "", - }).log, - ).toBe("Explore started\nfound target file"); - - tracker.delete("task-1"); - scheduler.advance(500); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "static", - }).log, - ).toBe("static"); }); }); diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts index 7034c4c506..60ad291058 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts @@ -1,361 +1,106 @@ import type { ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; -export interface OmpSubagentCardTimer { - readonly token: unknown; -} - -export interface OmpSubagentCardScheduler { - now(): number; - setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer; - clearTimeout(timer: OmpSubagentCardTimer): void; -} - -export interface OmpSubagentCardTrackerOptions { - scheduler?: OmpSubagentCardScheduler; - emitToolCall?: (toolCallId: string) => boolean; -} - -interface OmpSubagentLogLine { - key: string; - text: string; -} - -interface OmpSubagentCardItem { +interface OmpSubagentCardChild { index: number; description?: string; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; childSessionId?: string; - lines: OmpSubagentLogLine[]; - lineKeys: Set; -} - -interface OmpSubagentCardState { - items: Map; - emitToolCall: (toolCallId: string) => boolean; - lastEmitMs: number | null; - trailingTimer: OmpSubagentCardTimer | null; - dirty: boolean; -} - -const MAX_LOG_LINES_PER_ITEM = 200; -const MAX_LOG_LINE_CHARS = 240; -const THROTTLE_INTERVAL_MS = 500; - -export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler { - return { - now: () => Date.now(), - setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }), - clearTimeout: (timer) => { - clearTimeout(timer.token as ReturnType); - }, - }; } +/** + * Fills a task card's description and child session from OMP's sub-agent events. The card is a + * launch marker that names the task's first child; the children's activity belongs to their own + * timelines. + */ export class OmpSubagentCardTracker { - private readonly scheduler: OmpSubagentCardScheduler; - private readonly emitToolCall: (toolCallId: string) => boolean; - private readonly states = new Map(); - - constructor(options: OmpSubagentCardTrackerOptions) { - this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler(); - this.emitToolCall = options.emitToolCall ?? (() => false); - } + private readonly firstChildren = new Map(); handleLifecycle( payload: OmpSubagentLifecyclePayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.description, - status: payload.status === "started" ? "running" : payload.status, - childSessionId: payload.sessionFile, - }); - this.appendLine( - item, - `lifecycle:${payload.status}:${payload.id}`, - `${payload.id} ${payload.status}`, + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, ); - this.requestEmit(parentToolCallId); } handleProgress( payload: OmpSubagentProgressPayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.progress.description, - status: payload.progress.status, - childSessionId: payload.sessionFile, - }); - - const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool)); - if (currentToolLine) { - this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text); - } - - for (const tool of readRecordArray(payload.progress.recentTools)) { - const line = summarizeTool(tool); - if (line) { - this.appendLine(item, `tool:${line.key}`, line.text); - } - } - - for (const output of readOutputLines(payload.progress.recentOutput)) { - this.appendLine(item, `output:${output}`, output); - } - - if (payload.progress.status !== "running") { - this.appendLine( - item, - `status:${payload.progress.status}:${payload.progress.id}`, - `${payload.progress.id} ${payload.progress.status}`, - ); - } - - this.requestEmit(parentToolCallId); + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.progress.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, + ); } detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail { if (baseDetail.type !== "sub_agent") { return baseDetail; } - const state = this.states.get(toolCallId); - if (!state) { + const first = this.firstChildren.get(toolCallId); + if (!first) { return baseDetail; } - const items = [...state.items.values()].sort((left, right) => left.index - right.index); - const firstItem = items[0]; - if (!firstItem) { - return baseDetail; - } - - const detail: ToolCallDetail = { - type: "sub_agent", - ...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}), - ...((baseDetail.description ?? firstItem.description) - ? { description: baseDetail.description ?? firstItem.description } - : {}), - ...((firstItem.childSessionId ?? baseDetail.childSessionId) - ? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId } - : {}), - log: buildLog(items, baseDetail.log), + const description = baseDetail.description ?? first.description; + const childSessionId = first.childSessionId ?? baseDetail.childSessionId; + return { + ...baseDetail, + ...(description ? { description } : {}), + ...(childSessionId ? { childSessionId } : {}), }; - return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail; - } - - flush(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (!state || !state.dirty) { - return; - } - this.emitNow(toolCallId, state); } delete(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (state?.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - } - this.states.delete(toolCallId); + this.firstChildren.delete(toolCallId); } clear(): void { - for (const toolCallId of this.states.keys()) { - this.delete(toolCallId); - } + this.firstChildren.clear(); } - private upsertItem( - parentToolCallId: string, - input: { - index: number; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; - description?: string; - childSessionId?: string; - }, - ): OmpSubagentCardItem { - const state = this.stateFor(parentToolCallId); - const existing = state.items.get(input.index); - if (existing) { - existing.agent = readTrimmedString(input.agent) ?? existing.agent; - existing.status = input.status ?? existing.status; - existing.description = readTrimmedString(input.description) ?? existing.description; - existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId; - return existing; + private update( + parentToolCallId: string | undefined, + input: { index: number; description?: string; childSessionId?: string }, + emitToolCall: (toolCallId: string) => boolean, + ): void { + const toolCallId = readTrimmedString(parentToolCallId); + if (!toolCallId) { + return; } - - const item: OmpSubagentCardItem = { + const first = this.firstChildren.get(toolCallId); + if (first && input.index > first.index) { + return; + } + const existing = first?.index === input.index ? first : undefined; + const next: OmpSubagentCardChild = { index: input.index, - lines: [], - lineKeys: new Set(), - ...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}), - ...(input.status ? { status: input.status } : {}), - ...(readTrimmedString(input.description) - ? { description: readTrimmedString(input.description) } - : {}), - ...(readTrimmedString(input.childSessionId) - ? { childSessionId: readTrimmedString(input.childSessionId) } - : {}), + description: readTrimmedString(input.description) ?? existing?.description, + childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId, }; - state.items.set(input.index, item); - return item; - } - - private stateFor(parentToolCallId: string): OmpSubagentCardState { - const existing = this.states.get(parentToolCallId); - if (existing) { - return existing; - } - const state: OmpSubagentCardState = { - items: new Map(), - emitToolCall: this.emitToolCall, - lastEmitMs: null, - trailingTimer: null, - dirty: false, - }; - this.states.set(parentToolCallId, state); - return state; - } - - private appendLine(item: OmpSubagentCardItem, key: string, text: string): void { - const normalizedText = normalizeLine(text); - if (!normalizedText || item.lineKeys.has(key)) { + if ( + existing && + existing.description === next.description && + existing.childSessionId === next.childSessionId + ) { return; } - item.lines.push({ key, text: normalizedText }); - item.lineKeys.add(key); - while (item.lines.length > MAX_LOG_LINES_PER_ITEM) { - const removed = item.lines.shift(); - if (removed) { - item.lineKeys.delete(removed.key); - } - } + this.firstChildren.set(toolCallId, next); + emitToolCall(toolCallId); } - - private requestEmit(toolCallId: string): void { - const state = this.stateFor(toolCallId); - const now = this.scheduler.now(); - const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs; - if (elapsedMs >= THROTTLE_INTERVAL_MS) { - this.emitNow(toolCallId, state); - return; - } - - state.dirty = true; - if (state.trailingTimer) { - return; - } - state.trailingTimer = this.scheduler.setTimeout(() => { - state.trailingTimer = null; - if (state.dirty) { - this.emitNow(toolCallId, state); - } - }, THROTTLE_INTERVAL_MS - elapsedMs); - } - - private emitNow(toolCallId: string, state: OmpSubagentCardState): void { - if (state.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - state.trailingTimer = null; - } - state.dirty = false; - state.lastEmitMs = this.scheduler.now(); - state.emitToolCall(toolCallId); - } -} - -function buildLog(items: OmpSubagentCardItem[], fallback: string): string { - const itemCount = Math.max(...items.map((item) => item.index)) + 1; - const lines = items.flatMap((item) => { - const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : ""; - return item.lines.map((line) => `${prefix}${line.text}`); - }); - return lines.length > 0 ? lines.join("\n") : fallback; -} - -function summarizeTool(tool: Record | null): OmpSubagentLogLine | null { - if (!tool) { - return null; - } - const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name); - if (!toolName) { - return null; - } - const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input); - const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : ""; - return { - key: `${toolName}:${args ?? ""}${endMs}`, - text: args ? `[${toolName}] ${args}` : `[${toolName}]`, - }; -} - -function readOutputLines(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.flatMap((item) => { - const text = - readTrimmedString(item) ?? - (isRecord(item) - ? (readTrimmedString(item.text) ?? - readTrimmedString(item.output) ?? - readTrimmedString(item.content)) - : undefined); - return text ? [text] : []; - }); -} - -function readRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function readRecordArray(value: unknown): Record[] { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function summarizeUnknown(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - if (typeof value === "string") { - return readTrimmedString(value); - } - const json = JSON.stringify(value); - return typeof json === "string" ? normalizeLine(json) : undefined; -} - -function normalizeLine(value: string): string | undefined { - const normalized = value.trim().replace(/\s+/g, " "); - if (!normalized) { - return undefined; - } - if (normalized.length <= MAX_LOG_LINE_CHARS) { - return normalized; - } - return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`; } function readTrimmedString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts index ae4c0d0d77..cc13f9001f 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts @@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => { expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull(); }); - test("uses task result text and transcript path as the best static replay detail", () => { + test("takes the child session from the task transcript path", () => { expect( mapOmpToolDetail( parseToolArgs("task", { @@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => { subAgentType: "explore", description: "Inspect the target files", childSessionId: "/tmp/omp-task-static/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts index 86b1ee53ef..500123758a 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts @@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail { const argRecord = isRecord(args) ? args : {}; const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined; - const resultText = extractTextFromToolResult(result); const childSessionId = readChildSessionId(result); return { type: "sub_agent", @@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail argRecord.assignment, ), ...(childSessionId ? { childSessionId } : {}), - log: resultText?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 81288f3e4f..6fef290d91 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession { ...item, status: "failed", error, - detail: - item.detail.type === "sub_agent" - ? { - ...item.detail, - log: [item.detail.log, error.message] - .filter((entry) => entry.trim().length > 0) - .join("\n"), - } - : item.detail, }, }, turnId, diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 8214ebb1d0..b3eb432f86 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z ]) .nullable(); -function formatLogEntry(value: unknown): string | undefined { - const outputText = readOutputText(value); - if (outputText) { - return outputText; - } - if (value === null || value === undefined) { - return undefined; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - function extractOpenCodeTaskSessionId(value: unknown): string | undefined { const text = readOutputText(value); if (text) { @@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined { function deriveOpencodeTaskDetail( input: unknown, output: unknown, - error: unknown, metadata: Record | undefined, ): ToolCallDetail | null { if (!isRecord(input)) { @@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail( return null; } - const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n"); const childSessionId = normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output); return { @@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail( ...(subAgentType ? { subAgentType } : {}), ...(description ? { description } : {}), ...(childSessionId ? { childSessionId } : {}), - log, + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; } @@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail( toolName: string, input: unknown, output: unknown, - error: unknown = null, metadata?: Record, ): ToolCallDetail { if (toolName.trim().toLowerCase() === "task") { - const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata); + const taskDetail = deriveOpencodeTaskDetail(input, output, metadata); if (taskDetail) { return taskDetail; } diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 1c9ba212e1..eb38a04ac6 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => { }); }); - it("maps completed task calls with final output log to sub_agent detail", () => { + it("maps completed task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Found the CLI entrypoint and provider registry.", + log: "", actions: [], }); }); @@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => { subAgentType: "explore", description: "Explore current directory", childSessionId: "ses_2268db431ffe299vL1bbot8R7Z", - log: 'done', + log: "", actions: [], }); }); - it("maps aborted task calls with preserved error log to sub_agent detail", () => { + it("maps aborted task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Tool execution aborted", + log: "", actions: [], }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts index 8fbc861a84..261a4ec827 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim const error = raw.error ?? null; const rawStatus = typeof raw.status === "string" ? raw.status : undefined; const status = normalizeToolCallStatus(rawStatus, error, output); - const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata); + const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata); if (status === "failed") { return { diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts index c00e39aaad..c58f0faf15 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { extractTextFromToolResult } from "../../tool-call-mapper.js"; import type { PiChildSessionFile, PiExtension } from "../contract.js"; import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js"; @@ -99,7 +98,8 @@ export const edxethPiSubagents: PiExtension = { detail: { type: "sub_agent", description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }, subagents, childSessions, diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index f89a9aefd3..7bb62a9d1f 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_502323", + item: { + type: "assistant_message", + text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.", + }, + }, + ]); }); test("correlates a background notification and result collection", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index e1869705f8..9383911c3d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = { outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); pendingFilesByAgent.delete(details.data.agentId); const childSessions = childSession(call.callId, file); + const childStatus = status(details.data.status); + // Without a session file, as on replay, the child's answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; const file = details.data.transcriptPath; diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts index 0bf32a9f2f..d374c1a02d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -210,7 +210,7 @@ describe("Pi extension host", () => { type: "sub_agent", subAgentType: "scout", description: "Inspect files", - log: "Found two files", + log: "", }, }), ); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts index 8e9db54047..740d66aadc 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => { type: "sub_agent", subAgentType: "reviewer", description: "Review the Pi mapper change", - log: "The mapper change preserves provider status.", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts index 64e814af3f..058171e8df 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -131,7 +131,8 @@ export const piSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: args.data.agent, description: args.data.task, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; const base = { type: "upsert" as const, diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts index 8a9ca61d08..96ba771594 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -54,8 +54,6 @@ export async function verifySubagentFixture( try { await session.startTurn("Delegate work"); for (const event of fixture.events) pi.latestSession().emit(event); - const immediate = live.filter((event) => event.type === "provider_subagent"); - expect(immediate.some((event) => event.event.type === "timeline")).toBe(false); pi.latestSession().finishTurn(); const replay: AgentStreamEvent[] = []; for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts index ad666bb4d3..60f3b12869 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -36,12 +36,17 @@ describe("@tintinweb/pi-subagents adapter", () => { }); expect(mapping?.subagents).toEqual([ expect.objectContaining({ id: "call-1", status: "completed" }), + { + type: "timeline", + id: "call-1", + item: { type: "assistant_message", text: "Wrapped up (turn limit)" }, + }, ]); expect(mapping?.detail).toEqual({ type: "sub_agent", subAgentType: "Explore", description: "Inspect", - log: "Wrapped up (turn limit)", + log: "", }); }); test("maps captured foreground lifecycle live and on replay", async () => { @@ -53,6 +58,18 @@ describe("@tintinweb/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_458926", + item: { + type: "assistant_message", + text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).", + }, + }, + ]); }); test("uses a structured notification to complete a background child", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); @@ -147,7 +164,7 @@ describe("@tintinweb/pi-subagents adapter", () => { expect(mapping?.detail).toEqual({ type: "sub_agent", description: undefined, - log: "Wrapped up (turn limit)", + log: "", }); }, ); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts index 56b5935172..6858dec8be 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -57,7 +57,8 @@ export const tintinwebPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -84,6 +85,10 @@ export const tintinwebPiSubagents: PiExtension = { const file = outputFileFromToolResult(call.result); const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : []; if (file) readSessions.add(file); + const childStatus = status(details.data.status); + // A foreground child has no session file; its answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -93,8 +98,17 @@ export const tintinwebPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -110,7 +124,8 @@ export const tintinwebPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; return { diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts index 7eba32f22a..557f8360de 100644 --- a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts @@ -1,6 +1,3 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -10,7 +7,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -test("background cards retain child actions after the parent finishes (#6253)", async () => { +test("background children keep their actions after the parent finishes (#6253)", async () => { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-")); const logger = pino({ level: "silent" }); const daemon = await createTestPaseoDaemon({ @@ -62,22 +59,6 @@ test("background cards retain child actions after the parent finishes (#6253)", limit: 0, projection: "projected", }); - const { stdout } = await promisify(execFile)( - process.execPath, - [ - "--conditions=source", - "--import", - "tsx", - fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), - "--host", - `127.0.0.1:${daemon.port}`, - "logs", - agent.id, - ], - { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 }, - ); - expect(stdout).toContain("[Bash] ls -1"); - expect(stdout).toContain("[Bash] pwd"); expect( atFinish.entries.filter( (row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent", @@ -94,9 +75,6 @@ test("background cards retain child actions after the parent finishes (#6253)", ), ).toBe(true); } - for (const card of cards) { - expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]"); - } } finally { await client.close(); await daemon.close(); diff --git a/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts new file mode 100644 index 0000000000..bc78db2eab --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +// #2220: a timeline page is one WebSocket message, so a few huge entries used to close the socket. +test.each([40, 0])( + "fetching image-heavy history with limit %i keeps the direct connection usable", + async (limit) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(path.join(tmpdir(), "oversized-timeline-")); + try { + const agent = await ctx.client.createAgent({ provider: "codex", cwd }); + const imageUrl = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; + for (let index = 0; index < 5; index += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "tool_call", + callId: `screenshot-${index}`, + name: "node_repl.js", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { index }, + output: { _meta: { "codex/toolSurface": { screenshot: { url: imageUrl } } } }, + }, + }); + } + + const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit }); + const screenshots = page.entries.filter((entry) => entry.item.type === "tool_call"); + expect(screenshots).toHaveLength(5); + for (const entry of screenshots) { + expect(Buffer.byteLength(JSON.stringify(entry.item))).toBeLessThanOrEqual(64 * 1024); + } + + const next = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1 }); + expect(next.entries).toHaveLength(1); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +);