diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts index 143b9bd9002..6e9160624ba 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts @@ -68,6 +68,14 @@ export function assertSubagentOutput( assert.isNotNull(subagent.providerThreadId); assert.isNotNull(subagent.nativeTaskRef); assert.isNotNull(subagent.completedAt); + const parentItem = projection.turnItems.find( + (item) => item.type === "subagent" && item.subagentId === subagent.id, + ); + assert.isDefined(parentItem); + if (parentItem?.type !== "subagent") { + throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`); + } + assert.equal(parentItem.result, subagent.result); if (subagent.childThreadId === null) { throw new Error(`Subagent ${subagent.id} is missing its child thread`); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts index b3154161a67..aa73387fa84 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts @@ -47,6 +47,14 @@ export function assertSubagentV2Output( assert.isNotNull(subagent.providerThreadId); assert.isNotNull(subagent.nativeTaskRef); assert.isNotNull(subagent.completedAt); + const parentItem = projection.turnItems.find( + (item) => item.type === "subagent" && item.subagentId === subagent.id, + ); + assert.isDefined(parentItem); + if (parentItem?.type !== "subagent") { + throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`); + } + assert.equal(parentItem.result, subagent.result); if (subagent.childThreadId === null) { throw new Error(`Subagent ${subagent.id} is missing its child thread`); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b784a1cebaf..e4f37001a8b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -759,9 +759,273 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Open Package audit"'); expect(markup).toContain("Reading src/index.ts"); expect(markup).not.toContain("Inspect the package"); + expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); expect(markup).not.toContain("Work Log"); }); + it("discloses the full Codex subagent result without projecting child events", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain('data-v2-subagent-result-disclosure="true"'); + expect(markup).toContain('data-v2-subagent-result="true"'); + expect(markup).toContain('aria-label="Show full result for Isolation report"'); + expect(markup).toContain('aria-label="Open Isolation report"'); + expect(markup).toContain("Tests should be isolated."); + expect(markup).toContain("Result: no shared state."); + expect(markup).not.toContain("Explain test isolation"); + }); + + it("keeps live progress when a running subagent streams a partial result", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain('aria-label="Open Package audit"'); + expect(markup).toContain("Reading src/index.ts"); + expect(markup).not.toContain("Partial streamed answer so far"); + expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); + }); + + it("shows the streamed result while a subagent runs without progress", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain("Streaming answer so far"); + expect(markup).not.toContain("Inspect the package"); + expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); + }); + + it("treats a cancelled subagent result as partial output", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain("Partial output before cancel"); + expect(markup).not.toContain("Reading src/index.ts"); + expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); + }); + + it("falls back to progress when a completed subagent result is whitespace-only", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-item-type="subagent"'); + expect(markup).toContain("Audited 12 packages"); + expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); + }); + it("renders V2 provider failures as standalone error rows", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index e19ebd9ee1c..b609abe5665 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -2,6 +2,7 @@ import type { OrchestrationV2TurnItem, ThreadId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { BotIcon, + ChevronDownIcon, ExternalLinkIcon, GitForkIcon, MessageSquareIcon, @@ -28,6 +29,22 @@ export function isV2LifecycleItem(item: OrchestrationV2TurnItem): boolean { return LIFECYCLE_TYPES.has(item.type); } +// Aborted subagents (cancelled/interrupted) keep whatever result text had +// streamed before the abort, so only completed/failed results are final. +const FINAL_RESULT_SUBAGENT_STATUSES = new Set([ + "completed", + "failed", +]); + +// Once a subagent stops, its last streamed result says more than the stale +// progress line; while it runs, live progress comes first. +const TERMINAL_SUBAGENT_STATUSES = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); + export function V2LifecycleRow(props: { readonly item: OrchestrationV2TurnItem; readonly createdAt: string; @@ -115,14 +132,20 @@ export function V2LifecycleRow(props: { ); } if (item.type === "subagent") { + const streamedResult = item.result?.trim() ? item.result : null; + const finalResult = FINAL_RESULT_SUBAGENT_STATUSES.has(item.status) ? streamedResult : null; + const detail = TERMINAL_SUBAGENT_STATUSES.has(item.status) + ? (streamedResult ?? item.progress ?? item.prompt) + : (item.progress ?? streamedResult ?? item.prompt); return ( ); @@ -135,12 +158,14 @@ function RelatedThreadCard(props: { readonly icon: LucideIcon; readonly title: string; readonly detail: string; + readonly expandedDetail?: string | null; readonly badge: string; readonly threadId: ThreadId | null; readonly onOpenThread: (threadId: ThreadId) => void; }) { const Icon = props.icon; const threadId = props.threadId; + const expandedDetail = props.expandedDetail ?? null; const content = ( <> @@ -149,12 +174,46 @@ function RelatedThreadCard(props: { {props.badge} - {threadId === null ? null : ( -