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

+1 -1
View File
@@ -699,7 +699,7 @@ export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown"; itemId?: string }
| { kind: "thinking"; ts: string; text: string; delta?: boolean; lifecycle?: "started" | "completed"; channel?: "summary" | "detail" | "unknown"; itemId?: string }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string; /** Delegation (subagent) execution that made this call. */ parentToolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean }
| { kind: "init"; ts: string; model: string; sessionId: string }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
@@ -90,7 +90,7 @@
{
"path": "schemas/provider-event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
"sha256": "90dc4ad1d2387603e01d3c5d7e185cf4ffcc93676f930575ff290d90502d6582"
"sha256": "fb8473133d40f5ac878cdb317b38e29c84e3cb0b1aa59c78f72220f3f7349ea3"
},
{
"path": "schemas/provider-failure.schema.json",
@@ -63,6 +63,7 @@
"outputBytes": { "type": "integer", "minimum": 0 },
"outputTruncated": { "type": "boolean" },
"outputDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" },
"parentItemId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] },
"fileChanges": {
"description": "Files changed by this one edit call, each with a bounded patch. Paths are workspace-relative.",
"type": "array",
@@ -108,7 +109,7 @@
"delegationId": { "$ref": "#/$defs/id" },
"action": { "enum": ["spawn", "message", "resume", "wait", "close"] },
"status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] },
"children": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["childId", "role", "model", "status", "summary", "activitySummary"], "properties": { "childId": { "$ref": "#/$defs/id" }, "role": { "type": ["string", "null"], "maxLength": 160 }, "model": { "type": ["string", "null"], "maxLength": 240 }, "status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] }, "summary": { "$ref": "#/$defs/nullableShortText" }, "activitySummary": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } }
"children": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["childId", "role", "model", "status", "summary", "activitySummary"], "properties": { "childId": { "$ref": "#/$defs/id" }, "role": { "type": ["string", "null"], "maxLength": 160 }, "model": { "type": ["string", "null"], "maxLength": 240 }, "status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] }, "summary": { "$ref": "#/$defs/nullableShortText" }, "activitySummary": { "$ref": "#/$defs/nullableShortText" }, "prompt": { "$ref": "#/$defs/nullableShortText" }, "result": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } }
},
"additionalProperties": false
},
@@ -508,6 +508,13 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
)?;
let title = bounded_optional_text(payload, "title", 4_000, "runtime tool title")?;
bounded_optional_text(payload, "status", 100, "runtime tool status")?;
// Display lineage only: the delegation (subagent) call that made this one.
bounded_optional_text(
payload,
"parentToolCallId",
MAX_RUNTIME_ITEM_ID_CHARS,
"runtime tool parent",
)?;
if let Some(locations) = optional_array(payload, "locations", "runtime tool locations")?
{
if locations.len() > 2_000 {
@@ -1739,6 +1739,13 @@ fn normalize_acpx_tool_call(
if let Some(input_updated) = payload.get("inputUpdated").and_then(Value::as_bool) {
normalized["inputUpdated"] = Value::Bool(input_updated);
}
let parent = string(payload.get("parentToolCallId"));
if !parent.is_empty() {
let parent_item_id = acpx_opaque_item_id(parent, "", "tool");
if parent_item_id != item_id {
normalized["parentItemId"] = Value::String(parent_item_id);
}
}
if let Some(file_changes) =
crate::tool_file_changes::sanitize_file_changes(payload.get("fileChanges"))
{
@@ -2294,6 +2301,44 @@ mod tests {
}
}
#[test]
fn acpx_opaque_tool_ids_match_the_sidecar() {
// Shared vector with claude-subagent-lineage.test.ts acpxOpaqueToolItemId.
assert_eq!(
acpx_opaque_item_id("toolu_01Abc", "", "tool"),
"toolu_01Abc"
);
assert_eq!(
acpx_opaque_item_id("tool/with slash", "", "tool"),
"acpx-tool-65b834e51b2f923abbb92966fc162e52736b1f0bc7675c8170cf08df837ec5d4"
);
}
#[test]
fn links_a_subagent_child_tool_to_its_parent_call() {
let events = normalize_acpx_runtime_event(
AcpxRuntimeEventKind::ToolCall,
&json!({
"type": "tool_call", "toolCallId": "toolu_child", "tag": "tool_call",
"status": "pending", "title": "Read", "parentToolCallId": "toolu_agent",
}),
Some("read"),
"fallback",
"turn-1",
0,
);
assert_eq!(events[0].payload["parentItemId"], "toolu_agent");
let top = normalize_acpx_runtime_event(
AcpxRuntimeEventKind::ToolCall,
&json!({"type": "tool_call", "toolCallId": "toolu_top", "tag": "tool_call", "status": "pending", "title": "Read"}),
Some("read"),
"fallback",
"turn-1",
0,
);
assert!(top[0].payload.get("parentItemId").is_none());
}
#[test]
fn preserves_only_typed_acpx_input_update_marker() {
for input_updated in [
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { acpxOpaqueToolItemId, createClaudeSubagentLineageTracker } from "./claude-subagent-lineage.js";
import {
canonicalProviderEventsFromAcpxRuntimeEvent,
createAcpxToolEventNormalizer,
type AcpRuntimeEventShape,
} from "./provider-events.js";
import { validatePrpEvent } from "./protocol/replay-contract.js";
const SLASH_VECTOR = "acpx-tool-65b834e51b2f923abbb92966fc162e52736b1f0bc7675c8170cf08df837ec5d4";
function pipeline() {
const normalize = createAcpxToolEventNormalizer<AcpRuntimeEventShape>();
const track = createClaudeSubagentLineageTracker<AcpRuntimeEventShape>();
return (event: AcpRuntimeEventShape) =>
canonicalProviderEventsFromAcpxRuntimeEvent(track(normalize(event)), "fallback", "turn-1");
}
function valid(event: ReturnType<typeof canonicalProviderEventsFromAcpxRuntimeEvent>[number]) {
return validatePrpEvent({
schema: "paperclip.prp.event.v1",
sourceEventId: "validation:1", sourceSeq: 1, sourceInstanceId: "validation",
sourceKind: "runner", runId: "validation", normalizedSessionId: "validation",
turnId: "turn-1", itemId: event.itemId, eventType: event.eventType,
schemaVersion: 1, priority: 1, emittedAt: "2026-11-05T00:00:00.000Z", payload: event.payload,
});
}
describe("Claude subagent lineage", () => {
it("maps an Agent call to a delegation and links its child tools", () => {
const map = pipeline();
const started = map({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_agent", title: "Trace the retry path",
status: "pending", kind: "think",
rawInput: { subagent_type: "Explore", description: "Trace the retry path", prompt: "Find every retry." },
_meta: { claudeCode: { toolName: "Agent" } },
});
expect(started).toHaveLength(1);
expect(started[0]).toMatchObject({
eventType: "delegation.started",
itemId: "toolu_agent",
payload: {
schema: "paperclip.delegation.v1",
status: "running",
children: [{ childId: "toolu_agent", role: "Explore", summary: "Trace the retry path", prompt: "Find every retry.", result: null }],
},
});
expect(valid(started[0]!).ok).toBe(true);
const progress = map({
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_agent",
_meta: { claudeCode: { subagentProgress: { summary: "Found scheduleRetry" } } },
});
expect(progress[0]).toMatchObject({
eventType: "delegation.updated",
payload: { children: [{ role: "Explore", prompt: "Find every retry.", activitySummary: "Found scheduleRetry" }] },
});
const child = map({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_child", title: "Read webhook-delivery.ts",
status: "pending", kind: "read",
_meta: { claudeCode: { toolName: "Read", parentToolUseId: "toolu_agent" } },
});
expect(child[0]).toMatchObject({ eventType: "tool.execution.started", payload: { parentItemId: "toolu_agent" } });
expect(valid(child[0]!).ok).toBe(true);
// A later frame without _meta keeps the parent link.
const childDone = map({ type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_child", status: "completed", rawOutput: "ok" });
expect(childDone[0]).toMatchObject({ eventType: "tool.execution.completed", payload: { parentItemId: "toolu_agent" } });
const done = map({
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_agent", status: "completed",
rawOutput: [{ type: "text", text: "Retries create a new row." }],
});
expect(done[0]).toMatchObject({
eventType: "delegation.completed",
payload: { status: "completed", children: [{ status: "completed", activitySummary: null, result: "Retries create a new row." }] },
});
expect(valid(done[0]!).ok).toBe(true);
});
it("keeps an async subagent running past its launch result until its own state settles", () => {
const map = pipeline();
map({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_async", status: "pending",
rawInput: { subagent_type: "Explore", description: "Find tests", prompt: "List tests." },
_meta: { claudeCode: { toolName: "Agent" } },
});
const launched = map({
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_async", status: "completed",
rawOutput: [{ type: "text", text: "Async agent launched successfully.\nagentId: a1 (internal ID)" }],
});
expect(launched[0]).toMatchObject({ eventType: "delegation.updated", payload: { status: "running", children: [{ status: "running", result: null }] } });
const settled = map({
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_async",
_meta: { claudeCode: { subagentState: { status: "completed", summary: "Two suites cover delivery." } } },
});
expect(settled[0]).toMatchObject({
eventType: "delegation.completed",
payload: { status: "completed", children: [{ status: "completed", result: "Two suites cover delivery." }] },
});
expect(valid(settled[0]!).ok).toBe(true);
});
it("marks a result cut at the schema bound so it never reads as complete", () => {
const map = pipeline();
map({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_long", status: "pending",
rawInput: { subagent_type: "Explore", description: "Long answer" },
_meta: { claudeCode: { toolName: "Agent" } },
});
const done = map({ type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_long", status: "completed", rawOutput: "x".repeat(9_000) });
const result = (done[0]!.payload.children as Array<{ result: string }>)[0]!.result;
expect(result.length).toBeLessThanOrEqual(4_000);
expect(result.endsWith("[Result truncated]")).toBe(true);
expect(valid(done[0]!).ok).toBe(true);
});
it("leaves top-level tools unchanged", () => {
const [event] = pipeline()({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_top", title: "Read a.ts", status: "pending", kind: "read",
_meta: { claudeCode: { toolName: "Read" } },
});
expect(event?.eventType).toBe("tool.execution.started");
expect(event?.payload).not.toHaveProperty("parentItemId");
});
it("names tool calls exactly like runner-core acpx_opaque_item_id", () => {
expect(acpxOpaqueToolItemId("toolu_01Abc")).toBe("toolu_01Abc");
// Shared vector with provider_events.rs acpx_opaque_tool_ids_match_the_sidecar.
expect(acpxOpaqueToolItemId("tool/with slash")).toBe(SLASH_VECTOR);
expect(acpxOpaqueToolItemId("t".repeat(161))).toMatch(/^acpx-tool-[0-9a-f]{64}$/);
});
it("keeps a parent link for a 240-byte tool id", () => {
const map = pipeline();
const parent = "p".repeat(200);
const [child] = map({
type: "tool_call", tag: "tool_call", toolCallId: "toolu_child", status: "pending", title: "Read",
_meta: { claudeCode: { toolName: "Read", parentToolUseId: parent } },
});
expect(child?.payload.parentItemId).toBeTruthy();
});
});
@@ -0,0 +1,180 @@
/**
* Claude subagent lineage over ACPX tool events.
*
* Claude runs a subagent through its `Agent` (formerly `Task`) tool. With
* native subagent sessions off, claude-agent-acp reports everything in the
* parent ACP session:
*
* - the spawning call is an ordinary tool call whose `_meta.claudeCode.toolName`
* is `Agent`/`Task` and whose `rawInput` carries `subagent_type`,
* `description`, `prompt` and optionally `model`;
* - each tool call the subagent makes carries
* `_meta.claudeCode.parentToolUseId`, the spawning call's id;
* - Paperclip's claude-agent-acp patch forwards the SDK's
* `agentProgressSummaries` text as a `tool_call_update` on the spawning call
* with `_meta.claudeCode.subagentProgress`, and an async subagent's terminal
* state and summary with `_meta.claudeCode.subagentState`. An async
* subagent's tool call itself completes as soon as it launches ("Async agent
* launched"), so that completion does not settle the subagent.
*
* ACP updates are deltas, so identity, prompt and the latest progress summary
* arrive on different frames. This tracker remembers them per tool call and
* annotates each event with a complete `paperclipSubagent` (spawning call) or
* `paperclipParentToolCallId` (child call). The canonical mapper reads only
* those annotations. All values are producer hints and grant no authority.
*/
import { createHash } from "node:crypto";
import type { AcpRuntimeEventShape } from "./provider-events.js";
export type AcpxSubagentState = "running" | "completed" | "failed" | "interrupted";
export interface AcpxSubagentIdentity {
role: string | null;
description: string | null;
prompt: string | null;
model: string | null;
progress: string | null;
/** Lifecycle owned by the subagent, when it differs from its tool call. */
state: AcpxSubagentState | null;
/** Subagent result text, when the provider reported one. */
result: string | null;
/** The tool call returned at launch; the subagent runs in the background. */
async: boolean;
}
export interface AcpxSubagentAnnotations {
paperclipSubagent?: AcpxSubagentIdentity;
paperclipParentToolCallId?: string;
}
const SUBAGENT_TOOL_NAMES = new Set(["agent", "task"]);
const MAX_TRACKED = 256;
// Matches the sidecar tool identity bound (MAX_RUNTIME_ITEM_ID_CHARS in Rust).
const MAX_ID_BYTES = 240;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function shortText(value: unknown, max: number): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
const MAX_RESULT_CHARS = 4_000;
const TRUNCATED_RESULT_MARKER = "\n\n[Result truncated]";
/** Bound a result to the schema limit, marking a cut so it never reads as complete. */
function boundedResult(value: string | null): string | null {
if (!value) return null;
return value.length > MAX_RESULT_CHARS
? `${value.slice(0, MAX_RESULT_CHARS - TRUNCATED_RESULT_MARKER.length).trimEnd()}${TRUNCATED_RESULT_MARKER}`
: value;
}
/** Text of an ACP tool result: a string or text content blocks, untruncated. */
export function acpToolResultText(rawOutput: unknown, max = 256_000): string | null {
if (typeof rawOutput === "string") return rawOutput.trim().slice(0, max) || null;
const blocks = Array.isArray(rawOutput) ? rawOutput : [];
const joined = blocks
.flatMap((block) => {
const value = record(block);
return value.type === "text" && typeof value.text === "string" ? [value.text] : [];
})
.join("\n\n")
.trim();
return joined.slice(0, max) || null;
}
const ASYNC_LAUNCH = /^Async agent launched/i;
function subagentState(value: unknown): AcpxSubagentState | null {
const status = typeof value === "string" ? value.toLowerCase() : "";
if (status === "completed") return "completed";
if (status === "failed" || status === "error") return "failed";
if (["killed", "stopped", "cancelled", "canceled"].includes(status)) return "interrupted";
return null;
}
/**
* runner-core's `acpx_opaque_item_id(value, _, "tool")`: the PRP item id Rust
* gives an ACPX tool call (and a child's `parentItemId`). The sidecar uses it
* for delegation ids so a parent and its children always agree.
*/
export function acpxOpaqueToolItemId(value: string): string {
if (/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) && Buffer.byteLength(value) <= 160) return value;
const digest = createHash("sha256")
.update("paperclip.acpx.opaque-item.v1\0tool\0")
.update(value)
.digest("hex");
return `acpx-tool-${digest}`;
}
function boundedId(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
return Buffer.byteLength(value) <= MAX_ID_BYTES ? value : null;
}
function remember<K, V>(map: Map<K, V>, key: K, value: V): void {
if (!map.has(key) && map.size >= MAX_TRACKED) {
const oldest = map.keys().next().value;
if (oldest !== undefined) map.delete(oldest);
}
map.set(key, value);
}
export function createClaudeSubagentLineageTracker<
T extends AcpRuntimeEventShape,
>(projectToolCallId: (toolCallId: string) => string = (id) => id): (event: T) => T & AcpxSubagentAnnotations {
const subagents = new Map<string, AcpxSubagentIdentity>();
const parents = new Map<string, string>();
return (event) => {
if (event.type !== "tool_call" || typeof event.toolCallId !== "string") return event;
const toolCallId = event.toolCallId;
const claudeCode = record(record(event._meta).claudeCode);
const toolName = shortText(claudeCode.toolName, 160)?.toLowerCase() ?? null;
const input = record(event.rawInput);
const progress = record(claudeCode.subagentProgress);
const known = subagents.get(toolCallId);
const isSubagent =
known !== undefined ||
(toolName !== null && SUBAGENT_TOOL_NAMES.has(toolName)) ||
typeof input.subagent_type === "string";
if (isSubagent) {
const terminal = record(claudeCode.subagentState);
const output = acpToolResultText(event.rawOutput);
const launchedAsync = output !== null && ASYNC_LAUNCH.test(output);
const reported = subagentState(terminal.status);
const next: AcpxSubagentIdentity = {
role: shortText(input.subagent_type, 160) ?? known?.role ?? null,
description: shortText(input.description, 4_000) ?? known?.description ?? null,
prompt: shortText(input.prompt, 4_000) ?? known?.prompt ?? null,
model: shortText(input.model, 240) ?? known?.model ?? null,
progress:
shortText(progress.summary, 4_000) ??
shortText(progress.description, 4_000) ??
known?.progress ??
null,
async: known?.async === true || launchedAsync,
state:
reported ??
known?.state ??
(known?.async === true || launchedAsync ? "running" : null),
result:
boundedResult(shortText(terminal.summary, MAX_RESULT_CHARS + 1)) ??
known?.result ??
(launchedAsync ? null : boundedResult(output)),
};
remember(subagents, toolCallId, next);
return { ...event, paperclipSubagent: next };
}
const rawParent = boundedId(claudeCode.parentToolUseId);
const parent = rawParent ? projectToolCallId(rawParent) : parents.get(toolCallId);
if (!parent || parent === toolCallId) return event;
remember(parents, toolCallId, parent);
return { ...event, paperclipParentToolCallId: parent };
};
}
@@ -29,7 +29,8 @@ import type {
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js";
import type { PaperclipQuestionSet } from "../contracts/question-set.js";
import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
import { boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
import { acpxOpaqueToolItemId, createClaudeSubagentLineageTracker } from "../claude-subagent-lineage.js";
import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js";
import { createKimiMessageProjection, type KimiProjectedMessageEvent } from "../drivers/acpx/kimi-message-boundary.js";
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
@@ -683,15 +684,44 @@ async function pumpTurn(
const kimiMessages = initializedAgent === "kimi" ? createKimiMessageProjection<AcpRuntimeEvent>() : null;
const normalizeMessage = kimiMessages ? kimiMessages.normalize : piMessages ? piMessages.normalize : initializedAgent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (event: AcpRuntimeEvent) => event;
// Claude subagent lineage reads the bounded `_meta` and `rawInput` before
// they are dropped at this boundary. Only display data leaves: a
// schema-checked delegation rich event and a child tool's parent id.
const trackSubagents = initializedAgent === "claude"
? createClaudeSubagentLineageTracker<AcpRuntimeEventShape>((id) => stableProviderIdentity(id, "tool"))
: null;
for await (const event of runtimeTurn.events) {
toolEvidence?.tool(event);
emit(
"runtime.event",
sanitizeRuntimeEvent(
normalizeMessage(normalizeToolEvent(boundRuntimeEventForNormalization(event))),
),
currentTurnId,
const sanitized = sanitizeRuntimeEvent(
normalizeMessage(normalizeToolEvent(boundRuntimeEventForNormalization(event))),
);
const lineage = trackSubagents && event.type === "tool_call" && typeof sanitized.toolCallId === "string"
? trackSubagents({
type: "tool_call",
toolCallId: sanitized.toolCallId,
tag: event.tag,
status: event.status,
rawInput: event.rawInput,
rawOutput: event.rawOutput,
_meta: boundedAcpMeta((event as { _meta?: unknown })._meta),
})
: null;
if (lineage?.paperclipParentToolCallId) sanitized.parentToolCallId = lineage.paperclipParentToolCallId;
emit("runtime.event", sanitized, currentTurnId);
if (lineage?.paperclipSubagent) {
// Rust names the tool call (and each child's parentItemId) with
// acpx_opaque_item_id; the delegation must use the same identity.
const delegationId = acpxOpaqueToolItemId(String(sanitized.toolCallId));
for (const mapped of canonicalProviderEventsFromAcpxRuntimeEvent(lineage, `${currentTurnId}:subagent`, currentTurnId)) {
const rich = withDelegationIdentity(mapped, delegationId);
try {
validateAcpxRichEvent(rich);
emit("runtime.rich_event", { ...rich }, currentTurnId);
} catch {
// Display-only lineage must never fail the provider turn.
}
}
}
}
const result = await runtimeTurn.result;
if (result.status === "completed") { piMessages?.settle(); kimiMessages?.settle(); }
@@ -1023,6 +1053,18 @@ type BoundedRuntimeToolEvent = AcpRuntimeEvent & {
paperclipFileChanges: ToolFileChange[] | null;
};
/** Re-key a single-child delegation event to the Rust tool identity. */
function withDelegationIdentity(
event: ReturnType<typeof canonicalProviderEventsFromAcpxRuntimeEvent>[number],
id: string,
): ReturnType<typeof canonicalProviderEventsFromAcpxRuntimeEvent>[number] {
if (event.payload.schema !== "paperclip.delegation.v1") return event;
const children = Array.isArray(event.payload.children)
? event.payload.children.map((child) => ({ ...(child as Record<string, unknown>), childId: id }))
: [];
return { ...event, itemId: id, payload: { ...event.payload, delegationId: id, children } };
}
let cachedWorkspaceRoots: { directory: string; roots: string[] } | null = null;
function sidecarWorkspaceRoots(): string[] {
const directory = openParams?.workingDirectory;
@@ -56,6 +56,7 @@ import {
createAcpxToolEventNormalizer,
createGrokMessageNormalizer,
} from "../../provider-events.js";
import { createClaudeSubagentLineageTracker } from "../../claude-subagent-lineage.js";
import {
canonicalRunnerToolName,
type RunnerToolCall,
@@ -1490,11 +1491,14 @@ class CodexAcpxSession implements HarnessSession {
const kimiMessages = this.#agent === "kimi" ? createKimiMessageProjection<AcpRuntimeEvent>() : null;
const normalizeMessage = piMessages ? piMessages.normalize : kimiMessages ? kimiMessages.normalize : this.#agent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (event: AcpRuntimeEvent) => event;
const trackSubagents = this.#agent === "claude"
? createClaudeSubagentLineageTracker<AcpRuntimeEvent>(activity.toolExecutionId ?? ((id) => id))
: (event: AcpRuntimeEvent) => event;
for await (const event of turn.events) {
toolEvidence?.tool(event);
const projected = activity.toolExecutionId && event.type === "tool_call" && typeof event.toolCallId === "string"
? { ...event, toolCallId: activity.toolExecutionId(event.toolCallId) } : event;
this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(projected)), turnId, ++index);
this.#mapRuntimeEvent(normalizeMessage(trackSubagents(normalizeToolEvent(projected))), turnId, ++index);
}
const result = await turn.result;
if (result.status === "completed") { piMessages?.settle(); kimiMessages?.settle(); }
@@ -51,6 +51,8 @@ export function acpxCapabilities(
typedEventFamilies: providerFamilyCapabilities({
plan: profile.plans === "semantic-only" ? "unsupported" : "available",
tool_execution: "available",
// Claude Agent/Task calls map to delegation with nested child tools.
...(agent === "claude" ? { delegation: "available" as const } : {}),
model_identity: "available",
review: agent === "grok" ? "unsupported" : "available",
provider_notice: agent === "grok" ? "unsupported" : "available",
@@ -2086,6 +2086,16 @@ export const providerEventSchema = {
],
"pattern": "^sha256:[a-f0-9]{64}$"
},
"parentItemId": {
"anyOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
},
"fileChanges": {
"description": "Files changed by this one edit call, each with a bounded patch. Paths are workspace-relative.",
"type": "array",
@@ -2297,6 +2307,12 @@ export const providerEventSchema = {
},
"activitySummary": {
"$ref": "#/$defs/nullableShortText"
},
"prompt": {
"$ref": "#/$defs/nullableShortText"
},
"result": {
"$ref": "#/$defs/nullableShortText"
}
},
"additionalProperties": false
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { redactCodexDiagnostic } from "./drivers/codex/diagnostic-redaction.js";
import type { AcpxSubagentAnnotations, AcpxSubagentIdentity } from "./claude-subagent-lineage.js";
import {
toolFileChangesFromAcpContent,
toolFileChangesFromCodexChanges,
@@ -1435,6 +1436,9 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
}
if (event.type === "tool_call") {
const status = canonicalStatus(event.status, "running");
const subagent = (event as AcpxSubagentAnnotations).paperclipSubagent;
if (subagent) return [acpxSubagentDelegationEvent(event, subagent, status, itemId)];
const parentToolCallId = (event as AcpxSubagentAnnotations).paperclipParentToolCallId;
const output =
typeof event.rawOutput === "string"
? event.rawOutput
@@ -1474,6 +1478,9 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
progress:
status === "running" ? text(event.text).slice(0, 4000) || null : null,
...boundedOutput(output),
...(parentToolCallId
? { parentItemId: safeId(parentToolCallId, "acpx-item") }
: {}),
...(fileChanges ? { fileChanges } : {}),
};
const terminal = status !== "running";
@@ -1535,6 +1542,52 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
return [];
}
/** A Claude Agent/Task call is a delegation with one child, keyed by the call. */
function acpxSubagentDelegationEvent(
event: AcpRuntimeEventShape,
subagent: AcpxSubagentIdentity,
status: ReturnType<typeof canonicalStatus>,
itemId: string,
): CanonicalProviderEvent {
// An async subagent outlives its tool call; its own state wins.
const childStatus =
subagent.state ??
(status === "running"
? "running"
: status === "completed"
? "completed"
: status === "failed"
? "failed"
: "interrupted");
return {
eventType:
childStatus === "running"
? event.tag === "tool_call"
? "delegation.started"
: "delegation.updated"
: "delegation.completed",
payload: {
schema: "paperclip.delegation.v1",
delegationId: itemId,
action: "spawn",
status: childStatus,
children: [
{
childId: itemId,
role: subagent.role,
model: subagent.model,
status: childStatus,
summary: subagent.description,
activitySummary: childStatus === "running" ? subagent.progress : null,
prompt: subagent.prompt,
result: childStatus === "running" ? null : subagent.result,
},
],
},
itemId,
};
}
export function canonicalOpenCodeDisplayToolName(
value: string,
callId = "",
@@ -1,8 +1,66 @@
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef14627011e02b30 100644
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2922,9 +2922,19 @@ export class ClaudeAcpAgent {
@@ -2494,7 +2494,33 @@
case "hook_response":
case "files_persisted":
break;
- case "task_progress":
+ case "task_progress": {
+ // Paperclip: a subagent's progress summary (SDK option
+ // agentProgressSummaries) has no ACP home while native
+ // subagent sessions are off. Attach it to the spawning
+ // Agent/Task tool call so the client can show it there.
+ const progressTask = session.liveBackgroundTasks.get(message.task_id);
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" &&
+ progressTask?.isSubagent &&
+ progressTask.parentToolUseId &&
+ (message.summary || message.description)) {
+ await sendUpdate({
+ sessionId: message.session_id,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: progressTask.parentToolUseId,
+ _meta: {
+ claudeCode: {
+ subagentProgress: {
+ ...(message.summary ? { summary: String(message.summary).slice(0, 2000) } : {}),
+ ...(message.description ? { description: String(message.description).slice(0, 2000) } : {}),
+ ...(message.last_tool_name ? { lastToolName: String(message.last_tool_name).slice(0, 160) } : {}),
+ },
+ },
+ },
+ },
+ });
+ }
await asyncTasks.taskProgress({
task_id: message.task_id,
description: message.description,
@@ -2503,6 +2529,7 @@
usage: message.usage,
});
break;
+ }
case "task_started":
// For subagent tasks `task_id` is the subagent's agent id (the
// SDK keys its task registry by agent id) and `tool_use_id` is
@@ -2550,6 +2577,7 @@
case "task_notification":
// The task settled — no further tool calls can originate
// from it, so its registry entry can be dropped.
+ await this.paperclipForwardSubagentState(session, message.session_id, message.task_id, message.tool_use_id ?? null, message.status, message.summary, sendUpdate);
await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
await asyncTasks.taskNotification({
task_id: message.task_id,
@@ -2571,6 +2599,7 @@
if (message.patch.status === "completed" ||
message.patch.status === "failed" ||
message.patch.status === "killed") {
+ await this.paperclipForwardSubagentState(session, message.session_id, message.task_id, undefined, message.patch.status, undefined, sendUpdate);
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
session.liveBackgroundTasks.delete(message.task_id);
}
@@ -2922,9 +2951,19 @@
amount: message.total_cost_usd,
currency: "USD",
},
@@ -25,7 +83,56 @@ index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef146270
},
});
}
@@ -5311,6 +5321,10 @@ export class ClaudeAcpAgent {
@@ -4506,6 +4545,48 @@
* `$/cancel_request`, and our local abort race settles even if the client
* ignores it. A `cancelled` outcome, request rejection, and local abort all
* surface the same "Tool use aborted" the callers already expect. */
+ /**
+ * Paperclip: an async subagent's Agent/Task tool call completes as soon as
+ * the subagent launches. With native subagent sessions off, report the
+ * subagent's own terminal state and summary on that tool call so the
+ * client can settle its subagent card. Sent once per task.
+ */
+ async paperclipForwardSubagentState(session, sessionId, taskId, toolUseId, status, summary, sendUpdate) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1")
+ return;
+ // The terminal task_updated branch drops the live registry entry
+ // before task_notification arrives, so remember subagent parents here.
+ const parents = (session.paperclipSubagentParents ??= new Map());
+ const task = session.liveBackgroundTasks.get(taskId);
+ if (task?.isSubagent && task.parentToolUseId)
+ parents.set(taskId, task.parentToolUseId);
+ const parentToolUseId = parents.get(taskId) ?? (task?.isSubagent ? toolUseId : undefined);
+ if (!parentToolUseId)
+ return;
+ // The terminal task_updated patch can precede task_notification, which
+ // alone carries the summary; dedupe only the notification.
+ const forwarded = (session.paperclipForwardedSubagentStates ??= new Set());
+ if (forwarded.has(taskId))
+ return;
+ if (toolUseId !== undefined || summary)
+ forwarded.add(taskId);
+ await sendUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: parentToolUseId,
+ _meta: {
+ claudeCode: {
+ subagentState: {
+ status: String(status ?? "completed").slice(0, 40),
+ // One character past the display bound lets the client mark a cut.
+ ...(summary ? { summary: String(summary).slice(0, 4001) } : {}),
+ },
+ },
+ },
+ },
+ });
+ }
async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
if (signal.aborted)
throw new Error("Tool use aborted");
@@ -5311,6 +5392,12 @@
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
@@ -33,10 +140,12 @@ index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef146270
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
+ // Subagent progress summaries (~30s) feed the task chat's subagent card.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { agentProgressSummaries: true }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -5318,7 +5332,9 @@ export class ClaudeAcpAgent {
@@ -5318,7 +5405,9 @@
includePartialMessages: true,
forwardSubagentText,
mcpServers: {
@@ -47,7 +156,7 @@ index 0214a509eb5e7b777a69e4e34637941c275d27b1..403be02df66e25eee29e7d5bef146270
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
@@ -5329,6 +5345,16 @@ export class ClaudeAcpAgent {
@@ -5329,6 +5418,16 @@
allowDangerouslySkipPermissions: ALLOW_BYPASS,
permissionMode: initialPermissionMode,
canUseTool: this.canUseTool(sessionId),
@@ -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
@@ -706,6 +706,14 @@ function fileChangeDiffEntries(
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
// A subagent's spawning call also arrives as a tool lifecycle on some
// transports. Its delegation card owns it, so the tool row is hidden.
const delegationIds = new Set<string>();
for (const event of events) {
if (!event.eventType.startsWith("delegation.")) continue;
const id = text(record(record(record(event.payload)?.prpEvent)?.payload)?.delegationId);
if (id) delegationIds.add(id);
}
const startedToolIds = new Set<string>();
const completedToolIds = new Set<string>();
let usageSummary: {
@@ -1001,6 +1009,33 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
// A delegation carries its children (subagent type, prompt, progress,
// result) to the task-chat subagent card, so keep the full snapshot.
if (
event.eventType.startsWith("delegation.")
&& payload.schema === "paperclip.delegation.v1"
&& text(payload.delegationId)
) {
const status = text(payload.status);
entries.push({
kind: "provider_activity",
ts,
family: "delegation",
eventType: event.eventType,
status: status === "failed"
? "failed"
: status === "interrupted"
? "interrupted"
: status === "completed" || status === "closed"
? "completed"
: "running",
title: "Subagent",
summary: "Subagent",
payload,
});
continue;
}
const cardActivity = cardProviderActivityEntry(event, payload, ts);
if (cardActivity) {
entries.push(cardActivity);
@@ -1043,9 +1078,10 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
const executionId = text(payload.executionId);
if (!executionId) continue;
if (!executionId || delegationIds.has(executionId)) continue;
const nativeToolItem = nativeToolItemsById.get(executionId);
const presentation = toolPresentation(payload, nativeToolItem);
const parentToolUseId = text(payload.parentItemId);
if (!startedToolIds.has(executionId)) {
startedToolIds.add(executionId);
const fileChanges = fileChangesByExecutionId.get(executionId);
@@ -1059,6 +1095,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
? { ...presentation.input as Record<string, unknown>, path: editedPath }
: presentation.input,
toolUseId: executionId,
...(parentToolUseId ? { parentToolUseId } : {}),
});
entries.push(...fileChangeDiffEntries(fileChanges, executionId, ts));
}
+2
View File
@@ -598,6 +598,8 @@
/* Active-turn status island and its docked plan checklist. */
--sz-turn-status-island: min(100%, 42rem);
--sz-turn-plan-list-max-h: min(40dvh, 22rem);
/* A subagent result scrolls inside its card instead of taking over the thread. */
--sz-subagent-result-max-h: min(40dvh, 22rem);
/* Mobile pinned-composer bottom offset (PAP-495). The visible-nav offset
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
@@ -0,0 +1,276 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
import { TaskChatRunnerHeader } from "@/components/task-chat/TaskChatRunnerTurn";
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
import {
TaskChatSubagentCard,
TaskChatSubagentTree,
type TaskChatSubagent,
type TaskChatSubagentStatus,
} from "@/components/task-chat/TaskChatSubagentCard";
import type { TaskChatActivityPhaseItem, TaskChatToolStatus } from "@/components/task-chat/task-chat-model";
type Activity = TaskChatActivityPhaseItem["items"][number];
const NOW = Date.now();
function tool(id: string, name: string, target: string, status: TaskChatToolStatus = "completed", detail?: string): Activity {
return { id, kind: "tool", name, rawName: name, target, status, detail };
}
const RETRY_PATH_TOOLS: Activity[] = [
tool("a1", "Grep", "scheduleRetry", "completed", "server/src/services/webhook-delivery.ts:142\nserver/src/services/webhook-delivery.ts:211"),
tool("a2", "Read", "server/src/services/webhook-delivery.ts"),
tool("a3", "Read", "server/src/services/webhook-retry-policy.ts"),
tool("a4", "Grep", "deliveryAttempts"),
tool("a5", "Read", "packages/db/src/schema/webhook_deliveries.ts", "in_progress"),
];
const TEST_TOOLS: Activity[] = [
tool("b1", "Glob", "server/src/**/__tests__/*webhook*"),
tool("b2", "Read", "server/src/__tests__/webhook-delivery.test.ts"),
tool("b3", "Grep", "fakeTimers"),
];
const PROVIDER_TOOLS: Activity[] = [
tool("c1", "WebFetch", "docs.stripe.com/webhooks/best-practices"),
tool("c2", "WebSearch", "idempotency key webhook redelivery"),
tool("c3", "WebFetch", "docs.github.com/webhooks/using-webhooks/handling-webhook-deliveries"),
tool("c4", "WebFetch", "svix.com/resources/webhook-best-practices/retries"),
];
function subagent(
id: string,
agentType: string,
description: string,
status: TaskChatSubagentStatus,
activity: Activity[],
extra: Partial<TaskChatSubagent> = {},
): TaskChatSubagent {
const settled = status !== "running";
return {
id,
agentType,
description,
status,
activity: settled
? activity.map((item) => (item.kind === "tool" && item.status === "in_progress" ? { ...item, status: status === "failed" ? "failed" : "completed" } : item))
: activity,
startedAtMs: NOW - 48_000,
finishedAtMs: settled ? NOW - 4_000 : null,
...extra,
};
}
const RETRY_PROMPT =
"Find every place a webhook delivery gets retried. I need the scheduling function, the backoff policy, and where the attempt counter is stored. Report file paths with line numbers and say whether a retry reuses the original event id.";
const RETRY_RESULT = `Retries are scheduled in **\`scheduleRetry\`** (\`server/src/services/webhook-delivery.ts:142\`) with exponential backoff from \`webhook-retry-policy.ts\` (5 attempts, 30s base).
- The attempt counter lives on \`webhook_deliveries.attempt_count\`.
- A retry **creates a new delivery row** and does not copy \`event_id\`, which explains the duplicate deliveries.`;
const parallelLive = [
subagent("sa-retry", "Explore", "Trace the webhook retry path", "running", RETRY_PATH_TOOLS, {
prompt: RETRY_PROMPT,
model: "claude-haiku-5-5",
progressSummary: "Found scheduleRetry and the backoff policy; checking where attempt_count is stored",
}),
subagent("sa-tests", "Explore", "Find existing webhook delivery tests", "completed", TEST_TOOLS, {
prompt: "List the tests that cover webhook delivery and how they fake time.",
result: "Two suites: `webhook-delivery.test.ts` (fake timers via `vi.useFakeTimers`) and `webhook-routes.test.ts` (HTTP only). Neither covers a retried delivery.",
startedAtMs: NOW - 48_000,
finishedAtMs: NOW - 21_000,
}),
subagent("sa-docs", "general-purpose", "Check how providers dedupe redeliveries", "running", PROVIDER_TOOLS, {
prompt: "Read Stripe, GitHub and Svix guidance on webhook redelivery and summarize how receivers are expected to dedupe.",
progressSummary: "Stripe and GitHub both send a stable delivery id; reading Svix retry guidance",
}),
];
const parallelDone = [
subagent("sa-retry", "Explore", "Trace the webhook retry path", "completed", RETRY_PATH_TOOLS, {
prompt: RETRY_PROMPT,
model: "claude-haiku-5-5",
result: RETRY_RESULT,
}),
parallelLive[1]!,
subagent("sa-docs", "general-purpose", "Check how providers dedupe redeliveries", "completed", PROVIDER_TOOLS, {
prompt: parallelLive[2]!.prompt,
result: "All three send a stable id per event (`Stripe-Signature` timestamp + `evt_` id, `X-GitHub-Delivery`, `svix-id`) and expect receivers to dedupe on it.",
finishedAtMs: NOW - 9_000,
}),
];
const parallelFailed = [
parallelDone[0]!,
parallelDone[1]!,
subagent("sa-docs", "general-purpose", "Check how providers dedupe redeliveries", "failed", PROVIDER_TOOLS.slice(0, 2), {
prompt: parallelLive[2]!.prompt,
result: "WebFetch was denied for `docs.stripe.com`: the agent's network policy blocks external hosts.",
}),
];
const singleLive = subagent("sa-plan", "Plan", "Design an idempotent delivery fix", "running", [
tool("p1", "Read", "server/src/services/webhook-delivery.ts"),
tool("p2", "Read", "packages/db/src/schema/webhook_deliveries.ts"),
tool("p3", "Grep", "uniqueIndex", "in_progress"),
], {
prompt: "Using the findings below, propose the smallest change that makes retried deliveries reuse the original event id. Include the migration.",
progressSummary: "Weighing a unique index on (endpoint_id, event_id) against an upsert in scheduleRetry",
});
function phase(id: string, items: Activity[], active: boolean, interstitial?: string): TaskChatActivityPhaseItem {
return {
id,
kind: "activity_phase",
items,
summary: "",
active,
interstitial: interstitial ? { id: `${id}:msg`, kind: "message", author: "agent", text: interstitial, channel: "progress", interstitial: true } : undefined,
};
}
/** Mirrors TaskChatRunnerTurn's timeline: header, then rows with gap-2. */
function Turn({ status, children, final }: { status: string; children: ReactNode; final?: string }) {
return (
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
<TaskChatRunnerHeader runId="story-run" agentName="Engineer" agentIcon="code" status={status} startedAtMs={NOW - 62_000} finishedAtMs={status === "running" ? null : NOW} />
<div className="flex min-w-0 flex-col gap-2 py-1">{children}</div>
{final ? (
<TaskChatBubble item={{ id: "final", kind: "message", author: "agent", text: final, channel: "final", authorName: "Engineer" }} animateEntry={false} hideAgentIdentity />
) : null}
</div>
);
}
function Thread({ children, narrow }: { children: ReactNode; narrow?: boolean }) {
const memory = useMemo(() => new Map<string, boolean>(), []);
return (
<div className="min-h-screen bg-background text-foreground">
<main className={narrow ? "mx-auto flex w-full max-w-sm flex-col gap-6 px-4 py-8" : "mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-8"}>
<div className="self-end rounded-xl bg-muted px-4 py-3 text-sm">
Customers are getting the same webhook twice after a timeout. Can you find out why and fix it?
</div>
<TaskChatExpansionState.Provider value={memory}>{children}</TaskChatExpansionState.Provider>
</main>
</div>
);
}
const OPENING = phase("open", [tool("o1", "Grep", "webhook", "completed"), tool("o2", "Read", "server/src/services/webhook-delivery.ts")], false,
"I'll split this up: one subagent traces the retry path, one finds the existing tests, and one checks how providers expect receivers to dedupe.");
function ParallelLive({ narrow }: { narrow?: boolean }) {
return (
<Thread narrow={narrow}>
<Turn status="running">
<TaskChatRunnerActivityGroup item={OPENING} />
<TaskChatSubagentTree subagents={parallelLive} />
</Turn>
</Thread>
);
}
function SingleExpanded() {
return (
<Thread>
<Turn status="running">
<TaskChatRunnerActivityGroup item={phase("after", [], false, "The retry path creates a new row without the event id. I'll have a planning subagent design the fix.")} />
<TaskChatSubagentCard subagent={singleLive} defaultExpanded />
</Turn>
</Thread>
);
}
function Completed({ failed }: { failed?: boolean }) {
return (
<Thread>
<Turn
status={failed ? "succeeded" : "succeeded"}
final={failed
? "Retries create a new delivery row without the original `event_id`. I couldn't read provider docs (network policy), so the fix below follows the GitHub pattern I already know: dedupe on a stable delivery id."
: "Retries created a new delivery row without the original `event_id`, so receivers couldn't dedupe. `scheduleRetry` now reuses the event id and a unique index on `(endpoint_id, event_id)` stops duplicate rows. Added a regression test with fake timers."}
>
<TaskChatRunnerActivityGroup item={OPENING} />
<TaskChatSubagentTree subagents={failed ? parallelFailed : parallelDone} />
<TaskChatRunnerActivityGroup item={phase("fix", [
tool("f1", "Edit", "server/src/services/webhook-delivery.ts"),
tool("f2", "Write", "packages/db/src/migrations/0412_webhook_event_dedupe.sql"),
tool("f3", "Bash", "pnpm vitest run webhook-delivery"),
], false, "Retries drop the event id. I'll reuse it and add a unique index.")} />
</Turn>
</Thread>
);
}
/** Subagents start, report progress and finish on a loop. */
function LiveSimulation() {
const [tick, setTick] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setTick((value) => (value + 1) % 14), 1500);
return () => window.clearInterval(timer);
}, []);
const stage = (start: number, done: number, all: Activity[], base: TaskChatSubagent, doneVersion: TaskChatSubagent, summaries: string[]): TaskChatSubagent | null => {
if (tick < start) return null;
if (tick >= done) return doneVersion;
const step = tick - start;
const activity = all.slice(0, Math.min(all.length, step + 1)).map((item, index, list) =>
item.kind === "tool" ? { ...item, status: (index === list.length - 1 ? "in_progress" : "completed") as TaskChatToolStatus } : item);
return { ...base, activity, progressSummary: summaries[Math.min(summaries.length - 1, Math.floor(step / 2))] };
};
const children = [
stage(1, 9, RETRY_PATH_TOOLS, parallelLive[0]!, parallelDone[0]!, ["Searching for retry scheduling", "Reading webhook-delivery.ts", "Found scheduleRetry and the backoff policy; checking where attempt_count is stored"]),
stage(1, 5, TEST_TOOLS, { ...parallelLive[1]!, status: "running" }, parallelDone[1]!, ["Listing webhook tests", "Reading webhook-delivery.test.ts"]),
stage(2, 11, PROVIDER_TOOLS, parallelLive[2]!, parallelDone[2]!, ["Reading Stripe guidance", "Stripe sends a stable event id; checking GitHub", "Stripe and GitHub both send a stable delivery id; reading Svix retry guidance"]),
].filter((value): value is TaskChatSubagent => value !== null);
return (
<Thread>
<p className="text-xs text-muted-foreground">Simulated: subagents start, stream progress summaries every few seconds and finish; the loop restarts.</p>
<Turn status="running">
<TaskChatRunnerActivityGroup item={OPENING} />
{children.length > 0 ? <TaskChatSubagentTree subagents={children} /> : null}
</Turn>
</Thread>
);
}
const meta = {
id: "product-task-chat-subagents",
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Subagents",
component: TaskChatSubagentTree,
args: { subagents: parallelLive },
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Delegated subagents inside a runner turn. A Claude `Task`/`Agent` call (or a Codex collab child thread) becomes a collapsible card with the subagent type, prompt, live progress summary, nested tool rows and result. Parallel subagents dispatched together render as a small tree with status dots.",
},
},
paperclip: {
"capabilities": [
"tasks/discussion",
"runs-adapters/runs"
],
"status": "current",
"implementation": "production",
"boundary": "fixtures",
"notes": "Renders the shipped subagent card and tree inside the production runner header, activity groups and final bubble with fixture subagents. In the app the rows come from canonical delegation events and tool events carrying parentItemId; live provider event delivery is not exercised here."
},
},
render: () => <ParallelLive />,
} satisfies Meta<typeof TaskChatSubagentTree>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ParallelRunning: Story = { name: "01 · Parallel subagents · running", render: () => <ParallelLive /> };
export const SingleRunningExpanded: Story = { name: "02 · One subagent · expanded", render: () => <SingleExpanded /> };
export const ParallelCompleted: Story = { name: "03 · Completed turn", render: () => <Completed /> };
export const ParallelOneFailed: Story = { name: "04 · One subagent failed", render: () => <Completed failed /> };
export const LiveRun: Story = { name: "05 · Live simulation · animated", render: () => <LiveSimulation /> };
export const Mobile: Story = { name: "06 · Mobile", render: () => <ParallelLive narrow /> };
export const Light: Story = { name: "07 · Light", render: () => <ParallelLive />, globals: { theme: "light" } };