feat(ui): surface subagent waves in the transcript and on the board (#2225)

This commit is contained in:
nova28
2026-08-04 05:09:31 +08:00
committed by GitHub
parent b2518b9b8b
commit f372720ea9
23 changed files with 855 additions and 69 deletions
@@ -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 {
@@ -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.
@@ -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
+54 -13
View File
@@ -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 (
<span
data-testid="task-subagent-count"
title={label}
aria-label={label}
className="flex items-center gap-0.5 text-muted-foreground font-mono text-[10px]"
>
<IconUsersGroup className="h-3.5 w-3.5" aria-hidden="true" />
{count}
</span>
);
}
function OpenFullPageButton({
task,
onOpenFullPage,
}: {
task: Task;
onOpenFullPage: (task: Task) => void;
}) {
const { t } = useTranslation("common");
return (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onOpenFullPage(task);
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label={t("common:openFullPage")}
title={t("common:openFullPage")}
>
<IconArrowsMaximize className="h-4 w-4" />
</button>
);
}
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<string | null>(null);
const storeApi = useAppStoreApi();
@@ -355,21 +404,13 @@ function KanbanCardActions({
return (
<div className="flex items-center gap-2">
{renderSubagentCountChip(
task,
t("activeSubagents", { count: task.activeSubagentCount ?? 0 }),
)}
{statusIcon}
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onOpenFullPage(task);
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label="Open full page"
title="Open full page"
>
<IconArrowsMaximize className="h-4 w-4" />
</button>
<OpenFullPageButton task={task} onOpenFullPage={onOpenFullPage} />
)}
<KanbanCardMenu
task={task}
@@ -7,7 +7,8 @@ import {
IconMessageQuestion,
IconShieldQuestion,
} from "@tabler/icons-react";
import { renderTaskStatusIcon } from "./kanban-card-content";
import { renderToStaticMarkup } from "react-dom/server";
import { renderSubagentCountChip, renderTaskStatusIcon } from "./kanban-card-content";
import type { Task } from "./kanban-card";
function task(overrides: Partial<Task>): 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šëúđø šûɓåĝëñŧ"');
});
});
+2
View File
@@ -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;
@@ -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 (
<div
@@ -79,3 +79,36 @@ describe("subagentMetaChips", () => {
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" },
]);
});
});
@@ -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) {
@@ -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);
});
});
@@ -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}
</span>
)}
{showInlineWorking && (
@@ -192,6 +229,14 @@ function SubagentContent({
if (childMessages.length > 0) {
return (
<div className={cn(NESTED_BORDER, "space-y-2")}>
{resultText && (
<p
data-testid="subagent-result-text"
className="text-xs text-foreground/80 whitespace-pre-wrap break-words"
>
{resultText}
</p>
)}
{childMessages.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
@@ -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 && <SubagentMetaRow subagentTask={subagentTask} />}
{!isActive && !isExpanded && resultSummary && (
<p
data-testid="subagent-result-summary"
title={resultSummary}
className="ml-2 pl-4 text-xs text-foreground/80 truncate"
>
{resultSummary}
</p>
)}
{!isActive && <SubagentMetaRow subagentTask={subagentTask} childCount={childCount} />}
<SubagentContent
isExpanded={isExpanded}
childMessages={childMessages}
@@ -164,3 +164,36 @@ describe("TurnGroupMessage Codex subagent activity", () => {
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(
<TurnGroupMessage
group={{
type: "turn_group",
id: "turn-group-tool-1",
turnId: "turn-1",
messages: Array.from({ length: messageCount }, (_, i) =>
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");
});
});
@@ -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<string, Message>,
@@ -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;
@@ -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<RenderItem, { type: "turn_group" }>;
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"]);
});
});
+11 -1
View File
@@ -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) {
+1 -1
View File
@@ -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;
+3 -1
View File
@@ -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"
}
+7 -4
View File
@@ -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"
}
+3 -1
View File
@@ -11,5 +11,7 @@
"providerQuotaModel": "Ḿōďēĺ: {{model}}",
"providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.",
"providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.",
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ"
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ",
"turnGroupToolCalls_one": "ţōōĺ ćàĺĺ",
"turnGroupToolCalls_other": "ţōōĺ ćàĺĺś"
}
+4 -1
View File
@@ -75,5 +75,8 @@
"title": "Ţĩţĺē",
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś"
"workspaces": "Ŵōŕķśƥàćēś",
"activeSubagents_one": "{{count}} śũƀàĝēńţ ŕũńńĩńĝ",
"activeSubagents_other": "{{count}} śũƀàĝēńţś ŕũńńĩńĝ",
"openFullPage": "Ōƥēń ƒũĺĺ ƥàĝē"
}
+93
View File
@@ -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.
+1
View File
@@ -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 |
+168
View File
@@ -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`.