From f372720ea9447f723f022872aff0ec8f1b4b37dc Mon Sep 17 00:00:00 2001 From: nova28 Date: Tue, 4 Aug 2026 05:09:31 +0800 Subject: [PATCH] feat(ui): surface subagent waves in the transcript and on the board (#2225) --- .../server/adapter/transport/acp/subagent.go | 34 ++++ .../adapter/transport/acp/subagent_test.go | 91 ++++++++++ .../agentctl/types/streams/tool_payload.go | 9 +- apps/web/components/kanban-card-content.tsx | 67 +++++-- .../kanban-card-status-icon.test.tsx | 45 ++++- apps/web/components/kanban-card.tsx | 2 + .../task/chat/messages/subagent-meta-row.tsx | 10 +- .../task/chat/messages/subagent-meta.test.ts | 33 ++++ .../task/chat/messages/subagent-meta.ts | 16 +- .../messages/tool-subagent-message.test.tsx | 124 ++++++++++++- .../chat/messages/tool-subagent-message.tsx | 67 ++++++- .../chat/messages/turn-group-message.test.tsx | 33 ++++ .../task/chat/messages/turn-group-message.tsx | 36 +--- apps/web/hooks/use-processed-messages.test.ts | 57 ++++++ apps/web/hooks/use-processed-messages.ts | 12 +- apps/web/lib/state/slices/kanban/types.ts | 2 +- apps/web/src/locales/en/chat.json | 4 +- apps/web/src/locales/en/common.json | 11 +- apps/web/src/locales/pseudo/chat.json | 4 +- apps/web/src/locales/pseudo/common.json | 5 +- docs/plans/subagent-observability/plan.md | 93 ++++++++++ docs/specs/INDEX.md | 1 + docs/specs/ui/subagent-observability.md | 168 ++++++++++++++++++ 23 files changed, 855 insertions(+), 69 deletions(-) create mode 100644 docs/plans/subagent-observability/plan.md create mode 100644 docs/specs/ui/subagent-observability.md diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go index 9177eb2c56..bcbe920c34 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go @@ -276,9 +276,43 @@ func claudeSubagentResponse(meta map[string]any, res *SubagentTaskResult) bool { res.IsAsync, _ = resp["isAsync"].(bool) res.OutputFile, _ = resp["outputFile"].(string) res.CanReadOutputFile, _ = resp["canReadOutputFile"].(bool) + res.Model, _ = resp["resolvedModel"].(string) + res.ResultText = contentBlocksText(resp["content"]) return true } +// contentBlocksText concatenates the text of an ACP content-block array, in +// order, newline-separated. Non-text blocks (image, audio) and blank text are +// skipped; anything that isn't an array of block maps yields "". Defensive over +// untyped maps because the shape is provider-reported, and an absent result +// must read as "no result" rather than as a failure. +func contentBlocksText(v any) string { + blocks, ok := v.([]any) + if !ok { + return "" + } + parts := make([]string, 0, len(blocks)) + for _, b := range blocks { + block, isMap := b.(map[string]any) + if !isMap { + continue + } + if kind, _ := block["type"].(string); kind != contentTypeText { + continue + } + text, _ := block["text"].(string) + if strings.TrimSpace(text) == "" { + continue + } + parts = append(parts, text) + } + // Joined verbatim: TrimSpace is used only to decide whether a block is + // blank, never applied to the text that is stored. A verdict that opens + // with an indented code block must survive intact; truncation and + // first-line extraction are rendering concerns. + return strings.Join(parts, "\n") +} + // openCodeSubagentMetadata reads OpenCode's `rawOutput.metadata` into res and // composes Model as "providerID/modelID". func openCodeSubagentMetadata(out map[string]any, res *SubagentTaskResult) bool { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go index ca8d11883b..404f80fb53 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go @@ -1301,6 +1301,97 @@ func TestEnrichSubagentResult_Claude(t *testing.T) { } } +// The subagent's returned text and resolved model ride on the same +// `toolResponse` map as the metrics. Verified against a live claude-agent-acp +// 0.49.0 capture: `content` is an array of ACP content blocks and +// `resolvedModel` is the model the subagent actually ran on. +func TestEnrichSubagentResult_ClaudeResultTextAndModel(t *testing.T) { + n := NewNormalizer("") + payload := streams.NewSubagentTask("Review", "review it", "") + meta := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "status": "completed", + "resolvedModel": "claude-opus-4-8[1m]", + "content": []any{ + map[string]any{"type": "text", "text": "VERDICT: APPROVE"}, + map[string]any{"type": "text", "text": "No blocking findings."}, + }, + }}} + n.EnrichSubagentResult(payload, meta, nil) + sa := payload.SubagentTask() + if want := "VERDICT: APPROVE\nNo blocking findings."; sa.ResultText != want { + t.Errorf("ResultText = %q, want %q", sa.ResultText, want) + } + if sa.Model != "claude-opus-4-8[1m]" { + t.Errorf("Model = %q, want claude-opus-4-8[1m]", sa.Model) + } +} + +// Absent, empty, and all-non-text content must leave ResultText empty rather +// than substituting a placeholder — a subagent that returned nothing must not +// be made to look like one that failed. +func TestEnrichSubagentResult_ClaudeNoUsableResultText(t *testing.T) { + cases := map[string]any{ + "absent": nil, + "empty": []any{}, + "non-text": []any{map[string]any{"type": "image", "data": "…"}}, + "blank text": []any{map[string]any{"type": "text", "text": " "}}, + "wrong shape": []any{"just a string"}, + "not an array": map[string]any{"type": "text", "text": "nope"}, + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + n := NewNormalizer("") + payload := streams.NewSubagentTask("Review", "review it", "") + resp := map[string]any{"status": "completed"} + if content != nil { + resp["content"] = content + } + meta := map[string]any{"claudeCode": map[string]any{"toolResponse": resp}} + n.EnrichSubagentResult(payload, meta, nil) + if got := payload.SubagentTask().ResultText; got != "" { + t.Errorf("ResultText = %q, want empty", got) + } + }) + } +} + +// The payload contract is that captured text is stored verbatim; truncation is +// a rendering concern. Trimming the joined result would eat the indentation of +// a verdict that opens with a code block. +func TestEnrichSubagentResult_ClaudeResultTextKeepsIndentation(t *testing.T) { + n := NewNormalizer("") + payload := streams.NewSubagentTask("Review", "review it", "") + meta := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": " indented finding"}, + map[string]any{"type": "text", "text": "\tsecond line"}, + }, + }}} + n.EnrichSubagentResult(payload, meta, nil) + if want := " indented finding\n\tsecond line"; payload.SubagentTask().ResultText != want { + t.Errorf("ResultText = %q, want %q", payload.SubagentTask().ResultText, want) + } +} + +// Claude sends the metrics envelope and the terminal status on different +// frames, so enrichment runs more than once per subagent. A later frame +// without content must not erase text an earlier one captured. +func TestEnrichSubagentResult_ClaudeResultTextSurvivesLaterEmptyFrame(t *testing.T) { + n := NewNormalizer("") + payload := streams.NewSubagentTask("Review", "review it", "") + withText := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "content": []any{map[string]any{"type": "text", "text": "VERDICT: APPROVE"}}, + }}} + n.EnrichSubagentResult(payload, withText, nil) + noText := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "status": "completed", + }}} + n.EnrichSubagentResult(payload, noText, nil) + if got := payload.SubagentTask().ResultText; got != "VERDICT: APPROVE" { + t.Errorf("ResultText = %q, want it retained across frames", got) + } +} + // A completed subagent that ran zero tools must serialize tool_use_count: 0 // (not drop it), so the UI can render the "0 tools" chip. Regression test for // the omitempty + non-zero-guard bug. diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go index 66db2ef8a3..6d83d915f3 100644 --- a/apps/backend/internal/agentctl/types/streams/tool_payload.go +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -288,11 +288,10 @@ type SubagentTaskPayload struct { // Cursor) stay omitted rather than surfacing a misleading "0 tools" chip. ToolUseCount *int `json:"tool_use_count,omitempty"` - // ResultText is the final summary returned by the subagent. Populated for - // silent subagents (Auggie) that don't stream intermediate tool calls and - // only deliver a single text payload on completion via `rawOutput.output`. - // Claude/OpenCode/Cursor leave this empty because their progress is - // visible as nested child messages. + // ResultText is the final summary returned by the subagent. Auggie reads it + // from `rawOutput.output`; Claude reads text blocks from + // `_meta.claudeCode.toolResponse.content`. OpenCode and Cursor currently + // leave it empty because their progress is visible as nested child messages. ResultText string `json:"result_text,omitempty"` // Async/backgrounded subagent fields. Claude Code's Task tool with diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index cf66bbcff7..ae7d2ceeb0 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { CSS, type Transform } from "@dnd-kit/utilities"; import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; import { @@ -9,6 +10,7 @@ import { IconDots, IconLoader2, IconSubtask, + IconUsersGroup, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Card, CardContent } from "@kandev/ui/card"; @@ -277,6 +279,52 @@ export function renderTaskStatusIcon( ); } +// The board's only window into a fan-out. `activeSubagentCount` is derived from +// the live registry (never a mutable counter) and summed across a task's +// sessions, so it needs no local reconciliation: at zero there is nothing live +// and the chip is absent. +export function renderSubagentCountChip(task: Task, label: string) { + const count = task.activeSubagentCount ?? 0; + if (count <= 0) return null; + return ( + + + ); +} + +function OpenFullPageButton({ + task, + onOpenFullPage, +}: { + task: Task; + onOpenFullPage: (task: Task) => void; +}) { + const { t } = useTranslation("common"); + + return ( + + ); +} + function KanbanCardActions({ task, showMaximizeButton, @@ -285,6 +333,7 @@ function KanbanCardActions({ isDeleting, isArchiving, }: KanbanCardActionProps) { + const { t } = useTranslation("common"); const [menuOpen, setMenuOpen] = useState(false); const [storePrimarySessionState, setStorePrimarySessionState] = useState(null); const storeApi = useAppStoreApi(); @@ -355,21 +404,13 @@ function KanbanCardActions({ return (
+ {renderSubagentCountChip( + task, + t("activeSubagents", { count: task.activeSubagentCount ?? 0 }), + )} {statusIcon} {showMaximizeButton && onOpenFullPage && hasKnownSession && ( - + )} ): Task { @@ -90,3 +91,45 @@ describe("renderTaskStatusIcon — waiting-for-input variants", () => { expect(iconType(node)).toBe(IconShieldQuestion); }); }); + +// active_subagent_count has been published end-to-end since the background-work +// liveness work, and reached the store with no component reading it — rendering +// it was an explicit non-goal of that spec. This is the follow-up. +describe("renderSubagentCountChip", () => { + it("renders a chip carrying the count while subagents are live", () => { + const node = renderSubagentCountChip(task({ activeSubagentCount: 3 }), "3 subagents running"); + expect(isValidElement(node)).toBe(true); + expect(renderToStaticMarkup(node)).toContain("3"); + }); + + it("renders nothing at zero", () => { + expect( + renderSubagentCountChip(task({ activeSubagentCount: 0 }), "0 subagents running"), + ).toBeNull(); + }); + + it("renders nothing when the field is absent", () => { + expect(renderSubagentCountChip(task({}), "0 subagents running")).toBeNull(); + }); + + it("labels the chip with a pluralized count for assistive tech", () => { + expect( + renderToStaticMarkup( + renderSubagentCountChip(task({ activeSubagentCount: 1 }), "1 subagent running"), + ), + ).toContain('aria-label="1 subagent running"'); + expect( + renderToStaticMarkup( + renderSubagentCountChip(task({ activeSubagentCount: 2 }), "2 subagents running"), + ), + ).toContain('aria-label="2 subagents running"'); + }); + + it("uses the locale-subscribed label supplied by its component", () => { + expect( + renderToStaticMarkup( + renderSubagentCountChip(task({ activeSubagentCount: 1 }), "1 pšëúđø šûɓåĝëñŧ"), + ), + ).toContain('aria-label="1 pšëúđø šûɓåĝëñŧ"'); + }); +}); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index ed5e68ace0..af7def6ca6 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -61,6 +61,8 @@ export interface Task { * affordance on the card status icon. */ foregroundActivity?: ForegroundActivity | null; + /** Live subagents summed across this task's sessions; drives the count chip. */ + activeSubagentCount?: number; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; primaryExecutorId?: string | null; primaryExecutorType?: string | null; diff --git a/apps/web/components/task/chat/messages/subagent-meta-row.tsx b/apps/web/components/task/chat/messages/subagent-meta-row.tsx index 397185780b..ce401ad691 100644 --- a/apps/web/components/task/chat/messages/subagent-meta-row.tsx +++ b/apps/web/components/task/chat/messages/subagent-meta-row.tsx @@ -7,8 +7,14 @@ import { subagentMetaChips } from "@/components/task/chat/messages/subagent-meta * under a completed subagent's header. Renders nothing when no metric fields * are present, so the card degrades gracefully across agents. */ -export function SubagentMetaRow({ subagentTask }: { subagentTask?: SubagentTaskPayload }) { - const chips = subagentMetaChips(subagentTask); +export function SubagentMetaRow({ + subagentTask, + childCount, +}: { + subagentTask?: SubagentTaskPayload; + childCount?: number; +}) { + const chips = subagentMetaChips(subagentTask, childCount); if (chips.length === 0) return null; return (
{ expect(subagentMetaChips(payload)).toEqual([{ label: "background", value: "background" }]); }); }); + +// The card header already reads "10 tool calls" from the rendered child count. +// A "10 tools" chip beside it states the same fact twice, in two registers. +describe("subagentMetaChips tool-count de-duplication", () => { + it("omits the tools chip when it repeats the rendered child count", () => { + const payload: SubagentTaskPayload = { tool_use_count: 10, duration_ms: 90278 }; + const labels = subagentMetaChips(payload, 10).map((chip) => chip.label); + expect(labels).not.toContain("tools"); + expect(labels).toContain("duration"); + }); + + it("keeps the tools chip when the reported count differs from what streamed", () => { + expect(subagentMetaChips({ tool_use_count: 27 }, 20)).toEqual([ + { label: "tools", value: "27 tools" }, + ]); + }); + + it("keeps the tools chip when no children were rendered at all", () => { + expect(subagentMetaChips({ tool_use_count: 0 })).toEqual([ + { label: "tools", value: "0 tools" }, + ]); + }); + + // The card always passes a numeric childCount, so a zero-tool subagent hits + // 0 === 0 and would lose its "0 tools" chip — which is the one case the chip + // exists for. Passing undefined here (as the test above does) misses the + // production path entirely. + it("keeps the 0 tools chip when the card reports zero children", () => { + expect(subagentMetaChips({ tool_use_count: 0 }, 0)).toEqual([ + { label: "tools", value: "0 tools" }, + ]); + }); +}); diff --git a/apps/web/components/task/chat/messages/subagent-meta.ts b/apps/web/components/task/chat/messages/subagent-meta.ts index e925cc64f5..095502a89d 100644 --- a/apps/web/components/task/chat/messages/subagent-meta.ts +++ b/apps/web/components/task/chat/messages/subagent-meta.ts @@ -33,7 +33,10 @@ function formatTools(count: number): string { * are skipped when zero; tool_use_count is shown even at zero since "0 tools" * is meaningful for a completed subagent. */ -export function subagentMetaChips(payload: SubagentTaskPayload | undefined): SubagentMetaChip[] { +export function subagentMetaChips( + payload: SubagentTaskPayload | undefined, + childCount?: number, +): SubagentMetaChip[] { if (!payload) return []; const chips: SubagentMetaChip[] = []; @@ -50,7 +53,16 @@ export function subagentMetaChips(payload: SubagentTaskPayload | undefined): Sub if (typeof payload.total_tokens === "number" && payload.total_tokens > 0) { chips.push({ label: "tokens", value: formatTokens(payload.total_tokens) }); } - if (typeof payload.tool_use_count === "number") { + // The header already states the child count ("10 tool calls"), so a chip + // repeating the same number is noise. A divergence is not: it means the agent + // reported more tool uses than it streamed, which is worth seeing. The header + // renders no count at all when nothing streamed, so a zero child count can + // never be the duplicate — and "0 tools" is exactly the case the chip is for. + const headerStatesCount = typeof childCount === "number" && childCount > 0; + if ( + typeof payload.tool_use_count === "number" && + !(headerStatesCount && payload.tool_use_count === childCount) + ) { chips.push({ label: "tools", value: formatTools(payload.tool_use_count) }); } if (payload.model) { diff --git a/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx b/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx index b82889f83a..4f95cfba31 100644 --- a/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx +++ b/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx @@ -11,6 +11,10 @@ const SUBAGENT_CHEVRON = "subagent-chevron"; const IN_PROGRESS = "in_progress"; const STARTED = "started"; const WORKING = "Working..."; +const SUBAGENT_DESCRIPTION = "subagent-description"; +const SUBAGENT_RESULT_TEXT = "subagent-result-text"; +const CHILD_TOOL_LABEL = "Read SyncRunner.ts"; +const CODE_REVIEWER = "code-reviewer"; function subagentMessage({ metadataStatus = "in_progress", @@ -182,7 +186,7 @@ describe("ToolSubagentMessage", () => { ); expect(screen.getByTestId("subagent-type").textContent).toBe("verify"); - expect(screen.queryByTestId("subagent-description")).toBeNull(); + expect(screen.queryByTestId(SUBAGENT_DESCRIPTION)).toBeNull(); expect(screen.getByText(WORKING)).toBeTruthy(); expect(screen.queryByTestId(SUBAGENT_CHEVRON)).toBeNull(); expect(screen.queryByRole("button")).toBeNull(); @@ -239,10 +243,10 @@ describe("ToolSubagentMessage expansion", () => { ); expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false"); - expect(screen.queryByTestId("subagent-result-text")).toBeNull(); + expect(screen.queryByTestId(SUBAGENT_RESULT_TEXT)).toBeNull(); fireEvent.click(screen.getByRole("button")); - expect(screen.getByTestId("subagent-result-text").textContent).toBe( + expect(screen.getByTestId(SUBAGENT_RESULT_TEXT).textContent).toBe( "Probe completed successfully", ); }); @@ -269,7 +273,7 @@ describe("ToolSubagentMessage expansion", () => { />, ); - expect(screen.queryByTestId("subagent-result-text")).toBeNull(); + expect(screen.queryByTestId(SUBAGENT_RESULT_TEXT)).toBeNull(); }); it("keeps prompt-only subagents expandable", () => { @@ -304,3 +308,115 @@ describe("ToolSubagentMessage expansion", () => { expect(screen.queryByRole("button")).toBeNull(); }); }); + +// A reviewer's verdict is the only thing anyone reads a review-wave card for. +// It arrives on `toolResponse.content` and, before this, was shown only when +// the subagent streamed no child tool calls — i.e. never, for Claude. +describe("subagent result summary", () => { + const VERDICT = "VERDICT: REQUEST_CHANGES\nTwo blocking findings in SyncRunner.ts."; + + it("shows a one-line summary on the collapsed card even when children exist", () => { + renderSubagent( + subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE, resultText: VERDICT }), + { childMessages: [childTool("c1", CHILD_TOOL_LABEL)] }, + ); + const summary = screen.getByTestId("subagent-result-summary"); + expect(summary.textContent).toBe("VERDICT: REQUEST_CHANGES"); + expect(summary.textContent).not.toContain("Two blocking findings"); + }); + + it("shows the full result above the children once expanded", () => { + renderSubagent( + subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE, resultText: VERDICT }), + { childMessages: [childTool("c1", CHILD_TOOL_LABEL)] }, + ); + fireEvent.click(screen.getByTestId(SUBAGENT_CHEVRON).closest("button")!); + expect(screen.getByTestId(SUBAGENT_RESULT_TEXT).textContent).toBe(VERDICT); + expect(screen.getByText(CHILD_TOOL_LABEL)).toBeTruthy(); + }); + + it("stays silent when no result was captured", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE }), { + childMessages: [childTool("c1", CHILD_TOOL_LABEL)], + }); + expect(screen.queryByTestId("subagent-result-summary")).toBeNull(); + }); + + it("skips leading blank lines when picking the summary line", () => { + renderSubagent( + subagentMessage({ + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + resultText: "\n\n APPROVE \nrest", + }), + ); + expect(screen.getByTestId("subagent-result-summary").textContent).toBe("APPROVE"); + }); +}); + +// The type chip already says TEST-SUPERVISOR; repeating it as the first word of +// the description spent a third of a truncated line on a word already on screen. +describe("subagent description de-duplication", () => { + it("strips a leading restatement of the subagent type", () => { + renderSubagent( + subagentMessage({ + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + subagentType: "test-supervisor", + description: "Test-supervisor review of new invariant tests", + }), + ); + expect(screen.getByTestId(SUBAGENT_DESCRIPTION).textContent).toBe( + "review of new invariant tests", + ); + }); + + it("keeps a description that merely starts with a similar word", () => { + renderSubagent( + subagentMessage({ + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + subagentType: CODE_REVIEWER, + description: "code-review of the closure diff", + }), + ); + expect(screen.getByTestId(SUBAGENT_DESCRIPTION).textContent).toBe( + "code-review of the closure diff", + ); + }); + + it("renders no description when it exactly restates the type", () => { + renderSubagent( + subagentMessage({ + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + subagentType: CODE_REVIEWER, + description: CODE_REVIEWER, + }), + ); + expect(screen.queryByTestId(SUBAGENT_DESCRIPTION)).toBeNull(); + }); +}); + +// A description opening with a filename must not be eaten by a type that is a +// prefix of it: type "test" + "test.ts regression" must not become "ts +// regression". Only whitespace and a colon separate a type from its description. +describe("subagent description prefix boundaries", () => { + it.each([ + ["test", "test.ts regression suite", "test.ts regression suite"], + ["review", "review.md findings", "review.md findings"], + [CODE_REVIEWER, "code-reviewer: the closure diff", "the closure diff"], + [CODE_REVIEWER, "code-reviewer on diff", "on diff"], + ])("type %s + %s renders %s", (subagentType, description, expected) => { + cleanup(); + renderSubagent( + subagentMessage({ + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + subagentType, + description, + }), + ); + expect(screen.getByTestId(SUBAGENT_DESCRIPTION).textContent).toBe(expected); + }); +}); diff --git a/apps/web/components/task/chat/messages/tool-subagent-message.tsx b/apps/web/components/task/chat/messages/tool-subagent-message.tsx index 58f9cd92f1..9565cd658c 100644 --- a/apps/web/components/task/chat/messages/tool-subagent-message.tsx +++ b/apps/web/components/task/chat/messages/tool-subagent-message.tsx @@ -51,16 +51,30 @@ export function isSubagentEffectivelyActive( return isContainingTurnActive && (status === "in_progress" || payloadStatus === "started"); } +// The result is what the subagent was dispatched to produce, so it is shown +// whether or not the subagent also streamed child tool calls. The prompt is a +// fallback for subagents that reported neither. function deriveSubagentBody( childCount: number, subagentTask: SubagentTaskPayload | undefined, ): { resultText?: string; prompt?: string } { - if (childCount > 0) return {}; if (subagentTask?.result_text) return { resultText: subagentTask.result_text }; + if (childCount > 0) return {}; if (subagentTask?.prompt) return { prompt: subagentTask.prompt }; return {}; } +/** The collapsed card carries one line of the result — the first non-empty one, + * which is where agents put the verdict. Everything else waits for expand. */ +export function firstResultLine(resultText: string | undefined): string { + if (!resultText) return ""; + for (const line of resultText.split("\n")) { + const trimmed = line.trim(); + if (trimmed) return trimmed; + } + return ""; +} + function deriveSubagentDisplay( metadata: ToolCallMetadata | undefined, childCount: number, @@ -78,8 +92,30 @@ function deriveSubagentDisplay( }; } -function labelsMatch(left: string, right: string): boolean { - return left.trim().toLowerCase() === right.trim().toLowerCase(); +/** The type chip already names the subagent, so a description that opens by + * restating it ("test-supervisor" + "Test-supervisor review of new invariant + * tests") spends the line it truncates in on a word already on screen. The + * match ignores case and the separators agents vary on, and only strips at a + * word boundary — "code-reviewer" must not eat the "code-review" in + * "code-review of the closure diff". Returns "" when the description is + * nothing but the type. */ +export function stripSubagentTypePrefix(description: string, subagentType: string): string { + const normalize = (value: string) => value.toLowerCase().replace(/[\s\-_]+/g, ""); + const target = normalize(subagentType); + if (!target) return description; + let consumed = ""; + for (let i = 0; i < description.length; i++) { + consumed += normalize(description[i]); + if (consumed.length < target.length) continue; + if (consumed !== target) return description; + // Only whitespace or a colon separates a type from its description. `.` + // and `,` are not boundaries: type "test" would otherwise eat the filename + // in "test.ts regression suite" and leave "ts regression suite". + const rest = description.slice(i + 1); + if (rest !== "" && !/^[\s:]/.test(rest)) return description; + return rest.replace(/^[\s:]+/, ""); + } + return description; } type SubagentHeaderProps = { @@ -101,7 +137,8 @@ function SubagentHeader({ hasExpandableContent, onToggle, }: SubagentHeaderProps) { - const showDescription = !labelsMatch(subagentType, description); + const shownDescription = stripSubagentTypePrefix(description, subagentType); + const showDescription = shownDescription !== ""; const showInlineWorking = isActive && !hasExpandableContent; const content = ( <> @@ -129,7 +166,7 @@ function SubagentHeader({ title={description} className="font-mono text-xs truncate text-muted-foreground min-w-0" > - {description} + {shownDescription} )} {showInlineWorking && ( @@ -192,6 +229,14 @@ function SubagentContent({ if (childMessages.length > 0) { return (
+ {resultText && ( +

+ {resultText} +

+ )} {childMessages.map((child) => (
{renderChild(child)}
))} @@ -234,6 +279,7 @@ export const ToolSubagentMessage = memo(function ToolSubagentMessage({ const childCount = childMessages.length; const { subagentTask, isActive, resultText, prompt, hasExpandableContent } = deriveSubagentDisplay(metadata, childCount, isContainingTurnActive); + const resultSummary = firstResultLine(resultText); const description = subagentTask?.description || comment.content || "Subagent"; const subagentType = subagentTask?.subagent_type || "Task"; @@ -266,7 +312,16 @@ export const ToolSubagentMessage = memo(function ToolSubagentMessage({ hasExpandableContent={hasExpandableContent} onToggle={handleToggle} /> - {!isActive && } + {!isActive && !isExpanded && resultSummary && ( +

+ {resultSummary} +

+ )} + {!isActive && } { expect(settledHtml).not.toContain("Working..."); }); }); + +// The count badge renders `messages.length` next to this label, so the two must +// agree. The old label built its noun from a subagent-excluding count and read +// "3 tool call, 2 subagents" — wrong number, wrong plural. +describe("TurnGroupMessage collapsed label", () => { + function renderLabel(messageCount: number): string { + return renderToStaticMarkup( + + toolExecute(`tool-${i + 1}`, {}, `command-${i + 1}`), + ), + }} + sessionId="s1" + permissionsByToolCallId={new Map()} + />, + ); + } + + it("pluralizes with the count it displays", () => { + expect(renderLabel(2)).toContain("tool calls"); + const single = renderLabel(1); + expect(single).toContain("tool call"); + expect(single).not.toContain("tool calls"); + }); + + it("never mentions subagents, which are hoisted out of groups", () => { + expect(renderLabel(3)).not.toContain("subagent"); + }); +}); diff --git a/apps/web/components/task/chat/messages/turn-group-message.tsx b/apps/web/components/task/chat/messages/turn-group-message.tsx index 600e98b45a..421d024333 100644 --- a/apps/web/components/task/chat/messages/turn-group-message.tsx +++ b/apps/web/components/task/chat/messages/turn-group-message.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, memo, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import { cn, transformPathsInText } from "@/lib/utils"; @@ -26,20 +27,6 @@ type TurnGroupMessageProps = { onScrollToMessage?: (messageId: string) => void; }; -function countMessageTypes(messages: Message[]): { toolCalls: number; subagents: number } { - let toolCalls = 0; - let subagents = 0; - for (const msg of messages) { - const metadata = msg.metadata as ToolCallMetadata | undefined; - if (metadata?.normalized?.kind === "subagent_task") { - subagents++; - } else { - toolCalls++; - } - } - return { toolCalls, subagents }; -} - function getActiveGroupDescription(messages: Message[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -52,18 +39,6 @@ function getActiveGroupDescription(messages: Message[]): string { return "Working..."; } -function getCompletedGroupDescription(messages: Message[]): string { - const { toolCalls, subagents } = countMessageTypes(messages); - if (subagents === 0) return `tool call${toolCalls !== 1 ? "s" : ""}`; - if (toolCalls === 0) return `subagent${subagents !== 1 ? "s" : ""}`; - return `tool call${toolCalls !== 1 ? "s" : ""}, ${subagents} subagent${subagents !== 1 ? "s" : ""}`; -} - -function getGroupDescription(messages: Message[], isActive: boolean): string { - if (isActive) return getActiveGroupDescription(messages); - return getCompletedGroupDescription(messages); -} - function hasPendingPermission( messages: Message[], permissionsByToolCallId: Map, @@ -380,6 +355,7 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({ streamingMessageId, onScrollToMessage, }: TurnGroupMessageProps) { + const { t } = useTranslation("chat"); const isGroupRunning = hasRunningTool(group.messages, isTurnActive); const hasPending = hasPendingPermission(group.messages, permissionsByToolCallId); @@ -393,7 +369,13 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({ setManualExpandState((prev) => !(prev ?? autoExpanded)); }, [autoExpanded]); - const rawDescription = getGroupDescription(group.messages, isGroupRunning); + // The count badge beside this label renders `messages.length`, so the label + // has to agree with that same number. Subagents are hoisted out of turn + // groups (`isSubagentMessage` in use-processed-messages), so a group is tool + // calls and nothing else. + const rawDescription = isGroupRunning + ? getActiveGroupDescription(group.messages) + : t("turnGroupToolCalls", { count: group.messages.length }); const description = transformPathsInText(rawDescription, worktreePath); const count = group.messages.length; diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts index 824c5dcfb2..bb0631c492 100644 --- a/apps/web/hooks/use-processed-messages.test.ts +++ b/apps/web/hooks/use-processed-messages.test.ts @@ -421,3 +421,60 @@ describe("reconcileRenderItems", () => { expect(result).toHaveLength(2); }); }); + +function subagentCall(id: string, subagentType: string, turnId = "turn-1"): Message { + return { + ...makeMessage(id, "tool_call", { + status: "complete", + normalized: { kind: "subagent_task", subagent_task: { subagent_type: subagentType } }, + }), + content: `${subagentType} on diff`, + turn_id: turnId, + }; +} + +// A subagent is another agent's turn, not a tool the agent used. Collapsing one +// into a turn group hid entire review waves behind a "4 tool calls, 2 subagents" +// row 50 messages up the transcript. +describe("buildGroupedRenderItems subagent hoisting", () => { + it("renders subagents at top level and groups only the remaining tool calls", () => { + const items = buildGroupedRenderItems( + [ + toolExecute("t1"), + toolExecute("t2"), + subagentCall("s1", "code-reviewer"), + subagentCall("s2", "test-supervisor"), + ], + "s1", + { canAnchorPrepareProgress: false }, + ); + expect(items.map((i) => i.type)).toEqual(["turn_group", "message", "message"]); + const group = items[0] as Extract; + expect(group.messages.map((m) => m.id)).toEqual(["t1", "t2"]); + expect(items.slice(1).map((i) => (i as { message: Message }).message.id)).toEqual(["s1", "s2"]); + }); + + it("emits no group when a turn's only activity is subagents", () => { + const items = buildGroupedRenderItems( + [subagentCall("s1", "code-reviewer"), subagentCall("s2", "test-supervisor")], + "s1", + { canAnchorPrepareProgress: false }, + ); + expect(items.map((i) => i.type)).toEqual(["message", "message"]); + }); + + it("keeps tool calls on both sides of a subagent grouped separately", () => { + const items = buildGroupedRenderItems( + [ + toolExecute("t1"), + toolExecute("t2"), + subagentCall("s1", "code-reviewer"), + toolExecute("t3"), + toolExecute("t4"), + ], + "s1", + { canAnchorPrepareProgress: false }, + ); + expect(items.map((i) => i.type)).toEqual(["turn_group", "message", "turn_group"]); + }); +}); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index d6dc251525..d9d9e84aa5 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -419,6 +419,15 @@ export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] { }); } +/** A subagent is another agent's turn, not a tool the agent used, so it never + * collapses into a turn group. Grouping one hid whole review waves behind a + * "4 tool calls, 2 subagents" row far up the transcript; hoisting it keeps the + * wave legible at rest while the surrounding tool calls still collapse. */ +export function isSubagentMessage(message: Message): boolean { + const metadata = message.metadata as ToolCallMetadata | undefined; + return metadata?.normalized?.kind === "subagent_task"; +} + function groupActivityMessages(allMessages: Message[]): RenderItem[] { const items: RenderItem[] = []; let currentGroup: Message[] = []; @@ -440,7 +449,8 @@ function groupActivityMessages(allMessages: Message[]): RenderItem[] { }; for (const message of allMessages) { - const isActivity = message.type && ACTIVITY_MESSAGE_TYPES.has(message.type); + const isActivity = + message.type && ACTIVITY_MESSAGE_TYPES.has(message.type) && !isSubagentMessage(message); const messageTurnId = message.turn_id ?? null; if (isActivity && messageTurnId) { if (currentGroup.length > 0 && currentTurnId === messageTurnId) { diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index b8d48bf9dd..0ca4455abe 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -80,7 +80,7 @@ export type KanbanState = { * list background-running affordance. */ foregroundActivity?: ForegroundActivity | null; - /** Live subagents across this task's sessions; reserved for future UI. */ + /** Live subagents across this task's sessions; drives the board count chip. */ activeSubagentCount?: number; sessionCount?: number | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 3f33713caa..4593c4b5c7 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -11,5 +11,7 @@ "providerQuotaModel": "Model: {{model}}", "providerQuotaReset": "Capacity resets {{resetAt}}.", "providerQuotaResetUnknown": "Try again when the provider makes capacity available.", - "providerQuotaProviderFallback": "Provider" + "providerQuotaProviderFallback": "Provider", + "turnGroupToolCalls_one": "tool call", + "turnGroupToolCalls_other": "tool calls" } diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index ba28c58867..c9905f5191 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -57,14 +57,14 @@ "prompts": "Prompts", "requestFailed": "Request failed", "reset": "Reset", - "resetWatchChecking": "Checking how many tasks would be deleted…", + "resetWatchChecking": "Checking how many tasks would be deleted\u2026", "resetWatchDeleteAllTasks": "This will delete every task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchDeleteTasks_one": "This will delete {{count}} task previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchDeleteTasks_other": "This will delete {{count}} tasks previously created by the watch, including archived tasks. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", - "resetWatchNoTasks": "No tasks were created by this watch yet — only the polling state will be cleared. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", + "resetWatchNoTasks": "No tasks were created by this watch yet \u2014 only the polling state will be cleared. The watch's polling cursor is also cleared so the next check re-imports every currently-matching item. This cannot be undone.", "resetWatchPreviewFailedRequired": "Could not load the affected task count. Retry the preview before resetting this watch.", "resetWatchTitle": "Reset {{label}}?", - "resetting": "Resetting…", + "resetting": "Resetting\u2026", "retryPreview": "Retry preview", "save": "Save", "settings": "Settings", @@ -75,5 +75,8 @@ "title": "Title", "unsavedChanges": "Unsaved changes", "workspace": "Workspace", - "workspaces": "Workspaces" + "workspaces": "Workspaces", + "activeSubagents_one": "{{count}} subagent running", + "activeSubagents_other": "{{count}} subagents running", + "openFullPage": "Open full page" } diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index a0f00918c3..07c70825c7 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -11,5 +11,7 @@ "providerQuotaModel": "Ḿōďēĺ: {{model}}", "providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.", "providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.", - "providerQuotaProviderFallback": "Ƥŕōvĩďēŕ" + "providerQuotaProviderFallback": "Ƥŕōvĩďēŕ", + "turnGroupToolCalls_one": "ţōōĺ ćàĺĺ", + "turnGroupToolCalls_other": "ţōōĺ ćàĺĺś" } diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index eef594c662..7e6899cd26 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -75,5 +75,8 @@ "title": "Ţĩţĺē", "unsavedChanges": "Ũńśàvēď ćĥàńĝēś", "workspace": "Ŵōŕķśƥàćē", - "workspaces": "Ŵōŕķśƥàćēś" + "workspaces": "Ŵōŕķśƥàćēś", + "activeSubagents_one": "{{count}} śũƀàĝēńţ ŕũńńĩńĝ", + "activeSubagents_other": "{{count}} śũƀàĝēńţś ŕũńńĩńĝ", + "openFullPage": "Ōƥēń ƒũĺĺ ƥàĝē" } diff --git a/docs/plans/subagent-observability/plan.md b/docs/plans/subagent-observability/plan.md new file mode 100644 index 0000000000..b2797eb41c --- /dev/null +++ b/docs/plans/subagent-observability/plan.md @@ -0,0 +1,93 @@ +--- +spec: "../../specs/ui/subagent-observability.md" +status: building +created: 2026-08-03 +--- + +# Plan: Subagent Observability + +Five slices. Task 1 is backend-only; Tasks 2, 4, and 5 are independent. Task 3 +depends on Tasks 1 and 2 because it consumes the captured result data. Ordered +by value. + +Task detail is inline rather than in sibling `task-NN-*.md` files. Each slice +may change implementation, tests, locale files, or types, with one verification +command documented per slice. + +| # | Task | Layer | Depends on | +|---|---|---|---| +| 1 | Capture subagent result text and model | backend | — | +| 2 | Keep subagents out of turn groups | web | — | +| 3 | Surface the result on the card | web | 1 (for data), 2 (for reachability) | +| 4 | De-duplicate card metadata | web | — | +| 5 | Board subagent chip | web | — | + +## Task 1 — Capture subagent result text and model + +**Acceptance:** `claudeSubagentResponse` reads `content` and `resolvedModel` off +`_meta.claudeCode.toolResponse`. Text blocks concatenate newline-separated in +order into `ResultText`; non-text blocks are skipped; absent/empty/all-non-text +content yields an empty `ResultText` with no placeholder. `resolvedModel` +populates `Model`. The existing merge rule (a set value is not overwritten by a +later empty one) covers both fields. + +**Files:** `apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go`, +`subagent_test.go`. + +**Verification:** `make -C apps/backend test` (package `./internal/agentctl/...`). + +## Task 2 — Keep subagents out of turn groups + +**Acceptance:** turn grouping emits subagent messages at the group's own level in +chronological position; only non-subagent tool calls are collapsed. A group with +no remaining tool calls emits no collapsed row. The collapsed label's leading +numeral is the collapsed tool-call count and pluralizes with it; it never +mentions subagents. + +**Files:** `apps/web/hooks/use-processed-messages.ts`, +`apps/web/components/task/chat/messages/turn-group-message.tsx`, plus their tests. + +**Verification:** `cd apps/web && pnpm vitest run hooks/use-processed-messages.test.ts components/task/chat/messages/turn-group-message.test.tsx`. + +## Task 3 — Surface the result on the card + +**Acceptance:** the collapsed card shows a single-line summary — the first +non-empty line of `result_text`, ellipsized — and the expanded body shows the +full text above any child messages. No `result_text` means no summary line and no +placeholder. The description suppresses a leading restatement of the subagent +type, not only an exact match. + +**Files:** `apps/web/components/task/chat/messages/tool-subagent-message.tsx` +and tests; new copy keyed into `apps/web/src/locales/en/chat.json`. + +**Verification:** `cd apps/web && pnpm vitest run components/task/chat/messages/tool-subagent-message.test.tsx`. + +## Task 4 — De-duplicate card metadata + +**Acceptance:** the `tools` chip is omitted when the reported `tool_use_count` +equals the rendered child count, and kept when they diverge. + +**Files:** `apps/web/components/task/chat/messages/subagent-meta.ts`, +`subagent-meta-row.tsx`, `subagent-meta.test.ts`. + +**Verification:** `cd apps/web && pnpm vitest run components/task/chat/messages/subagent-meta.test.ts`. + +## Task 5 — Board subagent chip + +**Acceptance:** a kanban card renders a count chip while `activeSubagentCount > 0` +and nothing at zero or absent. Driven by the existing store field; no new API. + +**Files:** `apps/web/components/kanban-card-content.tsx`, +`apps/web/components/kanban-card-status-icon.test.tsx`; copy in +`apps/web/src/locales/en/common.json`. + +**Verification:** `cd apps/web && pnpm vitest run components/kanban-card-status-icon.test.tsx`. + +## Whole-feature validation + +- `make -C apps/backend test lint` +- `cd apps/web && pnpm run typecheck && pnpm lint && pnpm vitest run` +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` +- Local deploy: run the dev backend + SPA and confirm the B1 task + (`93ff1109-2a32-41c3-a550-dadf529395f6`) shows its four reviewer cards without + expanding a turn group. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 5cabca2330..10176bac56 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -143,6 +143,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [sidebar-view-creation](ui/sidebar-view-creation.md) | shipped | | [sidebar-task-completion-icons](ui/sidebar-task-completion-icons.md) | shipped | | [slash-command-composer](ui/slash-command-composer.md) | shipped | +| [subagent-observability](ui/subagent-observability.md) | building | | [entity-reference-composer](ui/entity-reference-composer.md) | draft | | [agent-launch-prompt-composer](ui/agent-launch-prompt-composer.md) | shipped | | [mermaid-rendering](ui/mermaid-rendering.md) | shipped | diff --git a/docs/specs/ui/subagent-observability.md b/docs/specs/ui/subagent-observability.md new file mode 100644 index 0000000000..5fcb891ad5 --- /dev/null +++ b/docs/specs/ui/subagent-observability.md @@ -0,0 +1,168 @@ +--- +status: building +created: 2026-08-03 +owner: nova28 +--- + +# Subagent Observability + +## Why + +An agent can dispatch subagents — a review wave, a research fan-out, a parallel +migration — and each one is a substantial unit of work: minutes of wall time, +tens of thousands of tokens, its own tool transcript. Kandev already ingests all +of that. It normalizes the ACP subagent frames, stores per-subagent identity and +metrics, nests each subagent's child tool calls under it, and derives a live +`active_subagent_count` per session and per task. + +None of it is reachable in practice. + +A real session (task `93ff1109`, two review waves, four subagents) renders its +reviewers only after the user scrolls back ~50 messages and expands a row +labelled `4 tool calls, 2 subagents` — the subagent cards are collapsed inside a +turn group that treats a 243-second reviewer as equivalent to +`cat > /tmp/prompt.txt`. The kanban board shows nothing at all: +`active_subagent_count` reaches the frontend store and no component reads it, +because rendering it was an explicit non-goal of +[background-work-liveness](../platform/background-work-liveness.md) and +[ADR 0049](../../decisions/0049-fine-grained-foreground-idle-busy-signal.md) +deliberately preserved subagent identities "for a future subagent list without +committing the current UI to one". This spec is that follow-up. + +The sharpest gap is that the card records what a subagent *did* but never what +it *returned*. For a review wave that is the whole point: an orchestrator's claim +to have run N reviewers cannot be checked against anything, because the verdicts +exist only in the orchestrator's own prose. A wire capture (`acpdbg`, +claude-agent-acp 0.49.0) confirms the returned text is already on the frame +kandev parses — `_meta.claudeCode.toolResponse.content` sits beside the +`totalTokens` and `totalDurationMs` the adapter already reads, and is stepped +over. + +## What + +### Capture + +- The Claude subagent result parser reads two additional fields off + `_meta.claudeCode.toolResponse`, the same map it already reads six fields from: + - `content` — an array of ACP content blocks. Text blocks are concatenated in + order (newline-separated) into the subagent payload's `result_text`. Non-text + blocks (image, audio) are skipped. Absent, empty, or all-non-text `content` + leaves `result_text` empty. + - `resolvedModel` — a string, into the payload's `model`. +- `result_text` is a general subagent-payload field, not a Claude-specific one: + its existing Auggie producer is unchanged, and a value already set by an + earlier frame is not overwritten by a later empty one. +- The captured text is stored verbatim on the message payload. Truncation is a + rendering concern, not a persistence one. + +### Transcript + +- A subagent is never collapsed into a turn group. When a turn group would + contain subagent messages, those messages render at the group's own level, in + chronological position, and the group collapses only the remaining tool calls. + A group left with no non-subagent tool calls renders no collapsed row. +- The collapsed face of a subagent card shows, when the data is present: the + subagent type, a description, the child tool-call count, duration, token count, + model, and the **first line of `result_text`** as a single-line result summary. +- The result summary is the first non-empty line of `result_text`, truncated with + an ellipsis to fit one line. The full `result_text` is shown in the expanded + body, above any nested child messages. +- A subagent with no captured `result_text` shows no result summary line and no + placeholder. Absence is silent — a subagent that returned nothing and an agent + that does not report results must not be made to look like a failure. +- The description is not rendered when it merely restates the subagent type: + the existing exact-match suppression is extended to also suppress a + description whose leading words are the subagent type (case- and + separator-insensitive, so `test-supervisor` suppresses the prefix of + `Test-supervisor review of new invariant tests`, leaving + `review of new invariant tests`). +- The `tools` metadata chip is suppressed when the reported `tool_use_count` + equals the rendered child-message count, since the header already states it. + A divergence between the two keeps both. + +### Turn-group label + +- A collapsed turn group's label states the counts it actually describes. The + leading numeral is the number of collapsed tool calls, and pluralization + agrees with it. +- Because subagents are no longer collapsed into groups, the label never + mentions subagents. + +### Board + +- A kanban card shows a subagent indicator whenever the task's + `active_subagent_count` is greater than zero: a small chip carrying the count, + next to the existing status affordances. +- The chip is live — it appears, updates, and disappears from the same + `active_subagent_count` already delivered by `task.updated` and the boot + payload, with no new API surface. +- At zero, or when the field is absent, no chip renders. + +## Scenarios + +- **GIVEN** a Claude subagent completes with `toolResponse.content` carrying text + blocks, **WHEN** the result is parsed, **THEN** the payload's `result_text` is + the concatenated text and its `model` is `resolvedModel`. +- **GIVEN** a `toolResponse` whose `content` is absent, empty, or contains only + non-text blocks, **WHEN** the result is parsed, **THEN** `result_text` is empty + and no placeholder text is substituted. +- **GIVEN** a payload whose `result_text` was already set by an earlier frame, + **WHEN** a later frame reports empty content, **THEN** the existing value is + retained. +- **GIVEN** a turn containing two subagents and four tool calls, **WHEN** the + transcript renders, **THEN** both subagent cards appear at top level and the + collapsed group reads `4 tool calls`. +- **GIVEN** a turn containing one tool call, **WHEN** the transcript renders, + **THEN** the collapsed group reads `1 tool call`. +- **GIVEN** a turn whose only tool activity is subagents, **WHEN** the transcript + renders, **THEN** the subagent cards appear and no collapsed group row renders. +- **GIVEN** a completed subagent with `result_text` spanning several lines, + **WHEN** its card is collapsed, **THEN** a single-line summary shows the first + non-empty line, and **WHEN** expanded, **THEN** the full text is shown above + any child messages. +- **GIVEN** a subagent with no `result_text`, **WHEN** its card renders, **THEN** + no result summary line appears. +- **GIVEN** a subagent of type `test-supervisor` described as + `Test-supervisor review of new invariant tests`, **WHEN** its header renders, + **THEN** the description shows without the redundant leading type. +- **GIVEN** a subagent reporting `tool_use_count` of 10 with 10 rendered child + messages, **WHEN** its metadata row renders, **THEN** the `tools` chip is + omitted; **GIVEN** a reported count that differs from the child count, **THEN** + both are shown. +- **GIVEN** a task whose `active_subagent_count` is two, **WHEN** its kanban card + renders, **THEN** a chip shows the count; **WHEN** the count drops to zero, + **THEN** the chip disappears without a reload. + +## Out of scope + +- Modelling a subagent as a kandev task or task session. Subagents stay inside + the parent session; this spec surfaces them, it does not promote them. +- `toolResponse.toolStats` (per-kind tool breakdown and line counts). Available + on the same map and worth a later pass; not captured here. +- Parsing a verdict taxonomy (APPROVE / REQUEST_CHANGES) out of `result_text`. + The text is surfaced verbatim; kandev does not interpret it. +- Result capture for non-Claude subagent dialects beyond the existing Auggie + path. OpenCode, Cursor, and Amp continue to report only what they report. +- A dedicated subagent list, drill-down panel, or per-subagent filtering on the + board. The board gets a count chip only. +- Any change to `active_subagent_count` derivation, liveness accounting, or the + busy-signal contract, all owned by + [background-work-liveness](../platform/background-work-liveness.md). + +## Notes + +- Wire evidence: `_meta.claudeCode.toolResponse` on a Claude Task completion + carries `agentId`, `agentType`, `status`, `content`, `prompt`, `resolvedModel`, + `toolStats`, `totalDurationMs`, `totalTokens`, `totalToolUseCount`, `usage`. + The adapter reads six of these in `claudeSubagentResponse` + (`apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go`). +- The same text also arrives on the terminal frame's `content[]` and + `rawOutput`, but that path is unreachable: `rawOutput` is a JSON array there + and `extractSubagentResult` type-asserts it to a map. The `toolResponse` path + is the one this spec uses; the array case is left alone. +- Frontend seams: `filterVisibleMessages` / turn grouping in + `apps/web/hooks/use-processed-messages.ts`, the card in + `apps/web/components/task/chat/messages/tool-subagent-message.tsx`, chips in + `subagent-meta.ts`, the group label in `turn-group-message.tsx`, and the board + chip in `apps/web/components/kanban-card-content.tsx` reading the + `activeSubagentCount` already mapped in `apps/web/lib/kanban/map-task.ts`.