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 (
+
+
+ {count}
+
+ );
+}
+
+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 (
+ )}
+ {!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`.