feat(task-chat): show Claude subagents as a collapsible tree in the runner turn (#19264)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task chat shows what an agent does in a run. The Paperclip
Runner sends this activity as canonical provider events.
> - Claude runs subagents through its Agent (Task) tool. A run that
delegates showed a confusing burst of unrelated tool calls.
> - The subagent data was already sent, but it was lost: ACPX dropped
`_meta`, the hardened sidecar dropped `rawInput`, and async subagents
report "launched" as the tool result.
> - This pull request maps each Agent call to a canonical `delegation`
event, links each child tool to its subagent, and shows subagents as a
collapsible card or tree.
> - The benefit is that operators can see what each subagent was asked,
what it is doing now, which tools it used, and what it returned.

## Linked Issues or Issue Description

Refs #19252 (the docked plan card used the same canonical-event pattern)
Refs #19259 (ACPX `_meta` passthrough, merged). This PR builds on it.

**What happened?**
When a Claude agent used its Agent tool, the task chat showed one
generic "Task" row and then the subagents' tool calls as flat rows in
the parent turn. The subagent type, prompt, progress and result were not
visible.

**Expected behavior**
Each subagent shows as its own card with its type, description, prompt,
live progress summary, nested tool rows and result. Subagents that start
together show as one tree with status dots.

**Steps to reproduce**
1. Assign a Claude (Paperclip Runner, ACPX) agent a task that asks it to
launch three Explore subagents in parallel.
2. Open the task while the run is live.
3. Before this change, the subagents' tool calls appear flat in the
parent turn.

## What Changed

- **claude-agent-acp patch:** Under the isolated runner context, the
patch turns on the SDK option `agentProgressSummaries`. It forwards each
subagent's `task_progress` summary and its terminal state and summary
onto the spawning tool call as `_meta.claudeCode.subagentProgress` and
`subagentState`. Native subagent sessions stay off, because ACPX cannot
follow child ACP sessions.
- **Runner:** The new file `claude-subagent-lineage.ts` tracks each
Agent/Task call across ACP update deltas. It maps the call to
`delegation.started|updated|completed` events and stamps child tools
with `parentItemId`. An async subagent stays running after its "Async
agent launched" result until its own state settles.
- **Sidecar path:** `acpx-runtime-sidecar.ts` reads the lineage before
it drops `_meta` and `rawInput`. Delegations go out as schema-checked
rich events. Child tools carry `parentToolCallId`, which Rust
(`acpx_event_payload.rs`, `provider_events.rs`) maps to `parentItemId`.
- **Contracts:** The provider-event schema adds optional `prompt` and
`result` to delegation children and optional `parentItemId` to tool
executions. The protocol modules are regenerated. ACPX Claude now
advertises the `delegation` event family.
- **UI:** `native-run-events.ts` keeps delegation payloads and the tool
parent link. `buildTurnTimelineRows` groups adjacent delegations into a
new `subagent_group` row, nests child tool rows (plain and
provider-activity shapes), and hides the spawning call's own tool row.
The new `TaskChatSubagentCard.tsx` renders the card and tree. A new size
token caps the result height.
- **Storybook:** The new story `Tasks and conversations/…/Task
chat/Subagents` has 7 states.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/cli
src/drivers/acpx src/provider-events.test.ts
src/claude-subagent-lineage.test.ts src/protocol` (2069 passed)
- `cargo test --release --locked -p paperclip-runner-core --lib` in
`packages/paperclip-runner/runner` (488 passed, including the new
parent-link test)
- `node --test test/protocol-contract.test.mjs
test/acpx-sidecar-contract.test.mjs
test/acpx-codex-package-contract.test.mjs
test/acpx-rich-extensions-package-contract.test.mjs` (48 passed)
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat src/components/transcript`, including the new
`subagent-timeline.test.ts`
- `check:protocol-types`, `check:protocol-manifest`,
`check:acpx-profiles` and `pnpm check:token-gates` pass.
- **Live test:** A local test-drive instance ran a Claude Opus 5.5 agent
on the Paperclip Runner (hardened sidecar path). The agent ran real
tasks that launched three parallel Explore subagents. The live view
showed "Running 3 subagents · 2 done" with a progress summary. The
settled view showed "Ran 3 subagents", each with its prompt, nested
tools and its own result text.

## Risks

- `agentProgressSummaries` makes Claude Code generate a short progress
summary for each running subagent about every 30 seconds. This adds a
small model cost to runs that delegate.
- The adapter patch reads `task_notification` and terminal
`task_updated` messages. A future claude-agent-acp version can change
these. The patch hunks then fail to apply, and the dependency bump must
update them.
- If a delegation event is lost, child tools stay at the top level as
before. Nothing is hidden without a matching delegation.
- Codex is not changed. Codex subagents need `multi_agent`, which
Paperclip keeps off on purpose. Changing that is a separate policy
decision.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, browser automation). It did the investigation,
implementation, tests and live test.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 18:58:35 +00:00
1 parent a695398052
commit 1eff07f29e
23 files changed
+1472 -19

No files matched your search

@@ -12,6 +12,7 @@ import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatSubagentTree } from "./TaskChatSubagentCard";
import { TaskChatRunHealthBanner, partitionRunHealthItems } from "./TaskChatRunHealthBanner";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
@@ -285,6 +286,8 @@ export function TaskChatRunnerTurn({
: "task-chat-plan-preview"
}
/>
) : row.kind === "subagent_group" ? (
<TaskChatSubagentTree subagents={row.subagents} />
) : row.kind === "protocol" ? (
<TaskChatProtocolCard
item={row}
@@ -0,0 +1,203 @@
import { useId } from "react";
import { Bot, ChevronRight } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import { useTaskChatExpansion } from "./expansion-state";
import type {
TaskChatActivityPhaseItem,
TaskChatSubagent,
TaskChatSubagentStatus,
} from "./task-chat-model";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
export type { TaskChatSubagent, TaskChatSubagentStatus } from "./task-chat-model";
/*
* A delegated subagent (Claude `Task`/`Agent` tool call, Codex collab child
* thread) shown as its own collapsible card inside the parent turn. Nested
* tool rows reuse the runner activity group so a child reads exactly like a
* parent turn, one level in.
*/
function formatElapsed(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
const STATUS_LABEL: Record<TaskChatSubagentStatus, string> = {
running: "Running",
completed: "Done",
failed: "Failed",
cancelled: "Stopped",
};
export function TaskChatSubagentStatusDot({ status }: { status: TaskChatSubagentStatus }) {
return (
<span className="relative flex size-5 shrink-0 items-center justify-center" data-subagent-status={status}>
{status === "running" ? (
<span className="absolute size-2 rounded-full bg-(--status-agent-running) opacity-60 motion-safe:animate-ping" aria-hidden />
) : null}
<span
className={cn(
"relative size-2 rounded-full",
status === "running" && "bg-(--status-agent-running)",
status === "completed" && "bg-(--status-task-icon-done)",
status === "failed" && "bg-(--status-task-icon-blocked)",
status === "cancelled" && "bg-muted-foreground",
)}
aria-hidden
/>
<span className="sr-only">{STATUS_LABEL[status]}</span>
</span>
);
}
function toolCount(subagent: TaskChatSubagent): number {
return subagent.activity.filter((item) => item.kind === "tool" || (item.kind === "protocol" && item.surface === "provider_activity")).length;
}
function useElapsed(subagent: TaskChatSubagent): string | null {
const running = subagent.status === "running";
useSecondTick(running);
if (subagent.startedAtMs == null) return null;
const end = running ? Date.now() : (subagent.finishedAtMs ?? null);
return end == null ? null : formatElapsed(end - subagent.startedAtMs);
}
function SubagentMeta({ subagent }: { subagent: TaskChatSubagent }) {
const elapsed = useElapsed(subagent);
const tools = toolCount(subagent);
const parts = [tools > 0 ? `${tools} ${tools === 1 ? "tool" : "tools"}` : null, elapsed].filter(Boolean);
if (parts.length === 0) return null;
return <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{parts.join(" · ")}</span>;
}
function SubagentBody({ subagent }: { subagent: TaskChatSubagent }) {
const running = subagent.status === "running";
const phase: TaskChatActivityPhaseItem = {
id: `${subagent.id}:activity`,
kind: "activity_phase",
items: subagent.activity,
summary: "",
active: running,
};
return (
<div className="flex min-w-0 flex-col gap-2 px-3 pb-3" data-testid="task-chat-subagent-body">
{subagent.prompt ? (
<details className="group min-w-0 text-xs text-muted-foreground">
<summary className="flex cursor-pointer list-none items-center gap-1 hover:text-foreground">
<ChevronRight className="size-3.5 shrink-0 group-open:rotate-90" aria-hidden />
Prompt{subagent.model ? <span className="font-mono"> · {subagent.model}</span> : null}
</summary>
<div className="mt-1 whitespace-pre-wrap break-words rounded-md bg-muted/40 p-2 leading-relaxed">{subagent.prompt}</div>
</details>
) : null}
{subagent.activity.length > 0 ? <TaskChatRunnerActivityGroup item={phase} /> : null}
{subagent.result ? (
<div className="max-h-(--sz-subagent-result-max-h) min-w-0 overflow-y-auto border-t border-border pt-2 text-sm text-foreground/90" data-testid="task-chat-subagent-result">
<MarkdownBody softBreaks>{subagent.result}</MarkdownBody>
</div>
) : null}
</div>
);
}
/** One subagent row: header always visible, body on expand. */
export function TaskChatSubagentCard({
subagent,
defaultExpanded,
nested = false,
}: {
subagent: TaskChatSubagent;
defaultExpanded?: boolean;
/** Inside a parallel tree: drop the outer border, the tree draws it. */
nested?: boolean;
}) {
const running = subagent.status === "running";
const [open, setOpen] = useTaskChatExpansion(`subagent:${subagent.id}`, defaultExpanded ?? false);
const bodyId = useId();
const liveLine = running ? subagent.progressSummary : undefined;
return (
<div
className={cn(
"min-w-0",
!nested && "rounded-lg border border-border bg-card",
nested && open && "rounded-md bg-muted/30",
)}
data-testid="task-chat-subagent-card"
data-subagent-id={subagent.id}
data-expanded={open}
>
<button
type="button"
className={cn(
"flex w-full min-w-0 items-start gap-2 rounded-lg px-3 py-2 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
nested && "px-2",
)}
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={open ? bodyId : undefined}
>
<span className="flex h-5 shrink-0 items-center">
{nested ? <TaskChatSubagentStatusDot status={subagent.status} /> : (
<span className="flex size-5 items-center justify-center text-muted-foreground"><Bot className="size-3.5" aria-hidden /></span>
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 rounded-sm bg-muted px-1.5 font-mono text-xs text-muted-foreground">{subagent.agentType}</span>
<span className="min-w-0 truncate text-sm text-foreground">{subagent.description}</span>
</span>
{liveLine ? (
<span className="min-w-0 truncate text-xs text-muted-foreground shimmer-text" data-testid="task-chat-subagent-progress">
{liveLine}
</span>
) : !running && !open && subagent.status !== "completed" ? (
<span className="text-xs text-(--status-task-icon-blocked)">{STATUS_LABEL[subagent.status]}</span>
) : null}
</span>
<span className="flex h-5 shrink-0 items-center gap-1.5">
<SubagentMeta subagent={subagent} />
{!nested ? <TaskChatSubagentStatusDot status={subagent.status} /> : null}
<ChevronRight className={cn("size-3.5 text-muted-foreground", open && "rotate-90")} aria-hidden />
</span>
</button>
{open ? <div id={bodyId}><SubagentBody subagent={subagent} /></div> : null}
</div>
);
}
/** Parallel subagents dispatched together, shown as a small tree. A single
* subagent renders as its own card with no group header. */
export function TaskChatSubagentTree({ subagents }: { subagents: readonly TaskChatSubagent[] }) {
if (subagents.length === 0) return null;
if (subagents.length === 1) return <TaskChatSubagentCard subagent={subagents[0]!} />;
const running = subagents.filter((subagent) => subagent.status === "running").length;
const failed = subagents.filter((subagent) => subagent.status === "failed").length;
const label = running > 0
? `Running ${subagents.length} subagents · ${subagents.length - running} done`
: failed > 0
? `Ran ${subagents.length} subagents · ${failed} failed`
: `Ran ${subagents.length} subagents`;
return (
<section className="min-w-0 rounded-lg border border-border bg-card" data-testid="task-chat-subagent-tree">
<div className="flex min-h-9 items-center gap-2 px-3 pt-2 text-xs text-muted-foreground">
<span className="flex size-5 items-center justify-center"><Bot className="size-3.5" aria-hidden /></span>
<span className={cn(running > 0 && "text-foreground")}>{label}</span>
</div>
<ol className="ml-5 flex min-w-0 flex-col gap-0.5 border-l border-border py-1 pl-2 pr-1 pb-2" aria-label="Subagents">
{subagents.map((subagent) => (
<li key={subagent.id} className="min-w-0">
<TaskChatSubagentCard subagent={subagent} nested />
</li>
))}
</ol>
</section>
);
}
@@ -19,6 +19,7 @@ import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatSubagentTree } from "./TaskChatSubagentCard";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatThinking } from "./TaskChatThinking";
@@ -236,6 +237,8 @@ function renderItem(
);
case "brief":
return renderBrief ? renderBrief() : null;
case "subagent_group":
return <TaskChatSubagentTree subagents={item.subagents} />;
case "turn":
return (
<TaskChatTurn
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
import type { TranscriptEntry } from "@/adapters";
import { buildTurnTimelineRows, paperclipRunnerTimelineItems, transcriptToTaskChatItems } from "./transcript-adapter";
import type { TaskChatSubagentGroupItem } from "./task-chat-model";
const RUN_ID = "10000000-0000-4000-8000-000000000001";
function event(seq: number, eventType: string, payload: Record<string, unknown>): HeartbeatRunEvent {
return {
id: seq,
companyId: "10000000-0000-4000-8000-000000000002",
runId: RUN_ID,
agentId: "10000000-0000-4000-8000-000000000003",
seq,
eventType,
stream: "system",
level: "info",
color: null,
message: null,
payload: {
prpEvent: {
schema: "paperclip.prp.event.v1",
sourceEventId: `event-${seq}`,
sourceSeq: seq,
sourceInstanceId: "runner-1",
sourceKind: "runner",
runId: RUN_ID,
normalizedSessionId: "session-1",
eventType,
schemaVersion: 1,
priority: 1,
emittedAt: `2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`,
payload,
},
},
createdAt: new Date("2026-11-05T18:00:00.000Z"),
};
}
function delegation(seq: number, id: string, status: string, child: Record<string, unknown>) {
return event(seq, status === "running" ? (seq < 3 ? "delegation.started" : "delegation.updated") : "delegation.completed", {
schema: "paperclip.delegation.v1",
delegationId: id,
action: "spawn",
status,
children: [{ childId: id, model: null, status, activitySummary: null, prompt: null, result: null, ...child }],
});
}
function tool(seq: number, id: string, done: boolean, parentItemId?: string) {
return event(seq, done ? "tool.execution.completed" : "tool.execution.started", {
schema: "paperclip.tool.execution.v1",
executionId: id,
transport: "builtin",
operation: "read",
name: "Read",
target: "server/src/webhook.ts",
status: done ? "completed" : "running",
output: done ? "ok" : null,
outputBytes: 0,
outputTruncated: false,
outputDigest: null,
...(parentItemId ? { parentItemId } : {}),
});
}
function rows(events: HeartbeatRunEvent[], running: boolean) {
const items = transcriptToTaskChatItems(nativeRunEventsToTranscript(events), { runId: RUN_ID, running });
return buildTurnTimelineRows(paperclipRunnerTimelineItems(items), running);
}
describe("subagent timeline rows", () => {
const spawn = [
delegation(1, "toolu_a", "running", { role: "Explore", summary: "Trace the retry path", prompt: "Find every retry." }),
delegation(2, "toolu_b", "running", { role: "general-purpose", summary: "Check provider docs" }),
tool(3, "toolu_a_read", false, "toolu_a"),
delegation(4, "toolu_a", "running", { role: "Explore", summary: "Trace the retry path", prompt: "Find every retry.", activitySummary: "Found scheduleRetry" }),
tool(5, "toolu_a_read", true, "toolu_a"),
tool(6, "toolu_b_fetch", false, "toolu_b"),
];
it("groups parallel subagents into one tree and nests their tools", () => {
const timeline = rows(spawn, true);
const groups = timeline.filter((row): row is TaskChatSubagentGroupItem => row.kind === "subagent_group");
expect(groups).toHaveLength(1);
const [a, b] = groups[0]!.subagents;
expect(a).toMatchObject({
agentType: "Explore",
description: "Trace the retry path",
prompt: "Find every retry.",
status: "running",
progressSummary: "Found scheduleRetry",
});
expect(a!.activity.map((item) => item.kind === "tool" && item.status)).toEqual(["completed"]);
expect(b!.activity).toHaveLength(1);
// Child tools never leak into the parent's activity phases.
expect(timeline.filter((row) => row.kind === "activity_phase")).toHaveLength(0);
});
it("hides the spawning call's own tool row", () => {
const timeline = rows([...spawn, tool(7, "toolu_a", false)], true);
expect(timeline.filter((row) => row.kind === "activity_phase")).toHaveLength(0);
});
it("settles results and marks a child cut off by the run as stopped", () => {
const timeline = rows([
...spawn,
delegation(7, "toolu_a", "completed", { role: "Explore", summary: "Trace the retry path", result: "Retries drop the event id." }),
tool(8, "toolu_top", true),
], false);
const group = timeline.find((row): row is TaskChatSubagentGroupItem => row.kind === "subagent_group")!;
expect(group.subagents.map((subagent) => subagent.status)).toEqual(["completed", "cancelled"]);
expect(group.subagents[0]!.result).toBe("Retries drop the event id.");
expect(group.subagents[1]!.activity.every((item) => item.kind !== "tool" || item.status !== "in_progress")).toBe(true);
// A top-level tool after the tree starts a normal phase.
expect(timeline.at(-1)?.kind).toBe("activity_phase");
});
it("nests live provider-activity tool rows and hides the spawning call", () => {
const ts = "2026-11-05T18:00:00.000Z";
const activity = (eventType: string, payload: Record<string, unknown>): TranscriptEntry => ({
kind: "provider_activity", ts, family: eventType.startsWith("delegation.") ? "delegation" : "tool_execution",
eventType, status: "running", title: "", summary: "", payload,
});
const entries: TranscriptEntry[] = [
activity("tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "toolu_a", name: "Task", status: "running" }),
activity("delegation.started", { schema: "paperclip.delegation.v1", delegationId: "toolu_a", action: "spawn", status: "running",
children: [{ childId: "toolu_a", role: "Explore", summary: "Find tests", status: "running" }] }),
activity("tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "toolu_b", name: "Task", status: "running" }),
activity("delegation.started", { schema: "paperclip.delegation.v1", delegationId: "toolu_b", action: "spawn", status: "running",
children: [{ childId: "toolu_b", role: "Explore", summary: "Find docs", status: "running" }] }),
activity("tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "toolu_a1", name: "Read", status: "running", parentItemId: "toolu_a" }),
];
const items = transcriptToTaskChatItems(entries, { runId: RUN_ID, running: true });
const timeline = buildTurnTimelineRows(paperclipRunnerTimelineItems(items), true);
expect(timeline.map((row) => row.kind)).toEqual(["subagent_group"]);
const group = timeline[0] as TaskChatSubagentGroupItem;
expect(group.subagents.map((subagent) => subagent.activity.length)).toEqual([1, 0]);
});
it("keeps a settled child's activity text when the provider sends no result", () => {
const timeline = rows([
delegation(1, "cursor_child", "completed", { role: "explore", summary: "Find tests", activitySummary: "Look for tests\nDuration: 1200 ms" }),
], false);
const group = timeline[0] as TaskChatSubagentGroupItem;
expect(group.subagents[0]).toMatchObject({ status: "completed", result: "Look for tests\nDuration: 1200 ms" });
});
});
@@ -227,6 +227,8 @@ export interface TaskChatToolItem {
diff?: TaskChatDiff;
/** Resolved permission decision badge, when one applied. */
decision?: "allowed" | "rejected";
/** Execution id of the delegation (subagent) that made this call. */
parentToolUseId?: string;
}
/**
@@ -379,6 +381,13 @@ export interface TaskChatProtocolChild {
status: string;
metadata?: string;
summary?: string;
/** Subagent type the provider dispatched (`Explore`, `general-purpose`). */
role?: string;
model?: string;
/** Latest provider progress summary while the child runs. */
progress?: string;
prompt?: string;
result?: string;
}
export interface TaskChatProviderActivityItem {
@@ -398,12 +407,50 @@ export interface TaskChatProviderActivityItem {
outputTruncated?: boolean;
/** Index of the latest provider event coalesced into this activity. */
transcriptIndex?: number;
/** Provider activity identity, e.g. the delegation id for a subagent. */
activityId?: string;
/** Delegation (subagent) execution that made this tool call. */
parentToolUseId?: string;
/** First and latest provider event times coalesced into this activity. */
startedAtMs?: number;
updatedAtMs?: number;
/** A retry status notice the turn has moved past. */
resolved?: boolean;
/** When the provider first reported this activity (ISO timestamp). */
startedAt?: string;
}
export type TaskChatSubagentStatus = "running" | "completed" | "failed" | "cancelled";
/**
* A delegated subagent (Claude Agent/Task call, Codex or Cursor child) with
* the tool rows it made, nested under the parent turn.
*/
export interface TaskChatSubagent {
id: string;
/** Subagent type the provider dispatched, e.g. `Explore` or `general-purpose`. */
agentType: string;
/** Short task description the parent wrote for the child. */
description: string;
prompt?: string;
model?: string;
status: TaskChatSubagentStatus;
/** Latest provider progress summary (Claude `agentProgressSummaries`). */
progressSummary?: string;
startedAtMs?: number | null;
finishedAtMs?: number | null;
/** Child tool/thinking rows, in order. */
activity: TaskChatActivityPhaseItem["items"];
result?: string;
}
/** Subagents dispatched together; one renders as a card, several as a tree. */
export interface TaskChatSubagentGroupItem {
id: string;
kind: "subagent_group";
subagents: TaskChatSubagent[];
}
export interface TaskChatWorkspaceChangeItem {
id: string;
kind: "protocol";
@@ -521,6 +568,7 @@ export type TaskChatProtocolItem =
/** Items a turn can group — everything except another turn. */
export type TaskChatTurnChildItem =
| TaskChatSubagentGroupItem
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
@@ -636,6 +684,7 @@ export type TaskChatItem =
| TaskChatPlanDocumentItem
| TaskChatTurnItem
| TaskChatBriefItem
| TaskChatSubagentGroupItem
| TaskChatProtocolItem;
/** Latest lifecycle state wins for each request; terminal entries suppress stale pending cards. */
@@ -23,6 +23,8 @@ import type {
TaskChatMessageItem,
TaskChatPlanDocumentItem,
TaskChatRuntimeRequestItem,
TaskChatSubagent,
TaskChatSubagentGroupItem,
} from "./task-chat-model";
import {
humanizeToolName,
@@ -455,6 +457,11 @@ function providerActivityItem(
.filter(Boolean)
.join(" · ") || undefined,
summary: stringValue(child.summary),
role: stringValue(child.role),
model: stringValue(child.model),
progress: stringValue(child.activitySummary),
prompt: stringValue(child.prompt),
result: stringValue(child.result),
}))
: [];
@@ -490,6 +497,21 @@ function providerActivityItem(
outputTruncated,
transcriptIndex: index,
startedAt: entry.ts,
...(entry.family === "tool_execution"
? {
activityId: stringValue(entry.payload.executionId),
parentToolUseId: stringValue(entry.payload.parentItemId),
}
: {}),
...(entry.family === "delegation"
? (() => {
const atMs = Date.parse(entry.ts);
return {
activityId: stringValue(entry.payload.delegationId),
...(Number.isFinite(atMs) ? { startedAtMs: atMs, updatedAtMs: atMs } : {}),
};
})()
: {}),
};
}
@@ -519,7 +541,12 @@ function mergeProviderActivityItem(
previous.family !== "tool_execution" ||
incoming.family !== "tool_execution"
) {
return { ...incoming, id: previous.id, startedAt: previous.startedAt ?? incoming.startedAt };
return {
...incoming,
id: previous.id,
startedAt: previous.startedAt ?? incoming.startedAt,
...(previous.startedAtMs != null ? { startedAtMs: previous.startedAtMs } : {}),
};
}
const details = new Map(
previous.details.map((detail) => [detail.label, detail]),
@@ -558,6 +585,7 @@ function mergeProviderActivityItem(
? incoming.summary
: previous.summary,
details: [...details.values()],
parentToolUseId: incoming.parentToolUseId ?? previous.parentToolUseId,
};
}
@@ -797,6 +825,7 @@ export function transcriptToTaskChatItems(
rawName: entry.name ?? undefined,
target: summarizeToolInput(entry.input),
status: "in_progress",
...(entry.parentToolUseId ? { parentToolUseId: entry.parentToolUseId } : {}),
});
toolIndexById.set(toolCallId, items.length - 1);
lastToolIndex = items.length - 1;
@@ -1765,12 +1794,69 @@ function phaseSummary(
* slot. Pending requests keep a compact feed entry so they remain reachable
* after their composer form is dismissed.
*/
function delegationItem(
item: TaskChatItem,
): TaskChatProviderActivityItem | null {
return (
item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.family === "delegation" &&
item.children.length > 0
) ? item : null;
}
function subagentStatus(
raw: string,
running: boolean,
): TaskChatSubagent["status"] {
if (raw === "completed" || raw === "closed") return "completed";
if (raw === "failed") return "failed";
if (raw === "interrupted" || raw === "cancelled") return "cancelled";
// A child still running after its run settled was cut off.
return running ? "running" : "cancelled";
}
/** One subagent per delegation child; the first child owns nested tools. */
function subagentsFromDelegation(
item: TaskChatProviderActivityItem,
running: boolean,
): TaskChatSubagent[] {
return item.children.map((child) => {
const status = subagentStatus(child.status, running);
return {
id: `${item.id}:${child.id}`,
agentType: child.role ?? child.title ?? "Subagent",
description: child.summary ?? child.role ?? "Subagent",
prompt: child.prompt,
model: child.model,
status,
progressSummary: status === "running" ? child.progress : undefined,
startedAtMs: item.startedAtMs ?? null,
finishedAtMs: status === "running" ? null : (item.updatedAtMs ?? null),
activity: [],
// Providers without a dedicated result (Cursor) keep their activity
// text in `activitySummary`; keep it readable once the child settles.
result: child.result ?? (status === "running" ? undefined : child.progress),
};
});
}
export function buildTurnTimelineRows(
parsed: readonly TaskChatItem[],
running: boolean,
): TaskChatTurnChildItem[] {
const rows: TaskChatTurnChildItem[] = [];
let current: TaskChatActivityPhaseItem | null = null;
// Subagents dispatched back to back share one tree; their own tool calls
// nest inside them instead of breaking the group.
let subagentGroup: TaskChatSubagentGroupItem | null = null;
const subagentByExecutionId = new Map<string, TaskChatSubagent>();
// Some transports also report the spawning call as a tool; the card owns it.
const delegationIds = new Set(
parsed.flatMap((item) =>
delegationItem(item)?.activityId ? [delegationItem(item)!.activityId!] : [],
),
);
const latestRequestByKey = new Map<string, TaskChatProtocolItem>();
for (const item of parsed) {
if (item.kind !== "protocol" || item.surface !== "runtime_request")
@@ -1805,6 +1891,48 @@ export function buildTurnTimelineRows(
return true;
});
for (const item of parsed) {
const delegation = delegationItem(item);
if (delegation) {
const subagents = subagentsFromDelegation(delegation, running);
if (delegation.activityId && subagents[0])
subagentByExecutionId.set(delegation.activityId, subagents[0]);
if (!subagentGroup || rows.at(-1) !== subagentGroup) {
subagentGroup = { id: `${delegation.id}:subagents`, kind: "subagent_group", subagents: [] };
rows.push(subagentGroup);
}
subagentGroup.subagents.push(...subagents);
current = null;
continue;
}
if (item.kind === "tool" && item.parentToolUseId) {
const parent = subagentByExecutionId.get(item.parentToolUseId);
if (parent) {
parent.activity.push(
parent.status === "running" || item.status !== "in_progress"
? item
: { ...item, status: parent.status === "failed" ? "failed" : "completed" },
);
continue;
}
}
if (
item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.family === "tool_execution"
) {
if (item.activityId && delegationIds.has(item.activityId)) continue;
const parent = item.parentToolUseId
? subagentByExecutionId.get(item.parentToolUseId)
: undefined;
if (parent) {
parent.activity.push(
parent.status === "running" || item.status !== "running"
? item
: { ...item, status: parent.status === "failed" ? "failed" : "completed" },
);
continue;
}
}
if (item.kind === "message") {
// Explicit final-channel replies remain canonical even when a later
// usage row makes them non-tail. Channel-less legacy transcripts retain