feat(ui): surface subagent waves in the transcript and on the board (#2225)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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šëúđø šûɓåĝëñŧ"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"providerQuotaModel": "Ḿōďēĺ: {{model}}",
|
||||
"providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.",
|
||||
"providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.",
|
||||
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ"
|
||||
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ",
|
||||
"turnGroupToolCalls_one": "ţōōĺ ćàĺĺ",
|
||||
"turnGroupToolCalls_other": "ţōōĺ ćàĺĺś"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,8 @@
|
||||
"title": "Ţĩţĺē",
|
||||
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
|
||||
"workspace": "Ŵōŕķśƥàćē",
|
||||
"workspaces": "Ŵōŕķśƥàćēś"
|
||||
"workspaces": "Ŵōŕķśƥàćēś",
|
||||
"activeSubagents_one": "{{count}} śũƀàĝēńţ ŕũńńĩńĝ",
|
||||
"activeSubagents_other": "{{count}} śũƀàĝēńţś ŕũńńĩńĝ",
|
||||
"openFullPage": "Ōƥēń ƒũĺĺ ƥàĝē"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user