From c5af99eea928f13229e5634e0dfe21a158ce2424 Mon Sep 17 00:00:00 2001 From: NoahLinckeScout Date: Thu, 27 Aug 2026 12:16:53 -0700 Subject: [PATCH 1/4] fix(server): classify ACP Task tool calls as collab agent tool calls A thread on an ACP-backed provider that launched subagents via its Task tool showed zero subagents in the UI. The launches were never dropped: over ACP they arrive as ordinary background tool calls titled "Task: ..." with `rawInput._toolName === "task"` and no agent identity on the wire, so `canonicalItemTypeFromAcpToolKind` filed them under its `dynamic_tool_call` default. Every consumer that makes delegated work visible keys off `collab_agent_tool_call`, so the rows sat in the timeline as anonymous tool noise. The other adapters already classify the same work as `collab_agent_tool_call` (`ClaudeAdapter.classifyToolItemType`, `OpenCodeAdapter`, `CodexAdapter`), so the shared ACP helper was the outlier. Recognize the Task spellings there so the ACP providers get the same classification: `rawInput._toolName === "task"` (case-insensitive) or a title matching `/^task:/i`. All other kinds are unchanged. Identity limits remain: ACP carries no model, agent type, or subagent prompt for these calls, so per-subagent attribution is still not possible. The row now at least reports that it was agent work. --- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 79 +++++++++++++++++++ .../src/provider/acp/AcpCoreRuntimeEvents.ts | 30 ++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 394ada83f763..1a0d17930e46 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -192,4 +192,83 @@ describe("AcpCoreRuntimeEvents", () => { }, }); }); + + it("classifies ACP Task tool calls as collab agent tool calls", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.make("turn-1"); + + for (const toolCall of [ + { + toolCallId: "toolu_task_1", + kind: "other", + status: "completed" as const, + title: "Task: Subagent task", + data: { toolCallId: "toolu_task_1", kind: "other", rawInput: { _toolName: "task" } }, + }, + { + toolCallId: "toolu_task_2", + kind: "other", + status: "completed" as const, + title: "task: research the flake layout", + data: { toolCallId: "toolu_task_2", kind: "other" }, + }, + { + toolCallId: "toolu_task_3", + kind: "other", + status: "inProgress" as const, + data: { toolCallId: "toolu_task_3", kind: "other", rawInput: { _toolName: "Task" } }, + }, + ]) { + expect( + makeAcpToolCallEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + toolCall, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + payload: { itemType: "collab_agent_tool_call" }, + }); + } + + expect( + makeAcpToolCallEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "toolu_other_1", + kind: "other", + status: "completed" as const, + title: "Custom MCP tool", + data: { toolCallId: "toolu_other_1", kind: "other", rawInput: { _toolName: "mcp__x" } }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + payload: { itemType: "dynamic_tool_call" }, + }); + + expect( + makeAcpToolCallEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "toolu_other_2", + kind: "search", + status: "completed" as const, + title: "Searched files", + data: { toolCallId: "toolu_other_2" }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + payload: { itemType: "web_search" }, + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index bd25e9815aef..0cdafd277580 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -60,6 +60,34 @@ function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecyc } } +/** + * Task-tool launches over ACP (Cursor, Grok) currently arrive as anonymous + * background tool calls titled "Task: Subagent task" with no agent identity on + * the wire. Classifying them as `dynamic_tool_call` renders them as generic + * tool rows, which is how a Cursor thread's delegated subagents became + * invisible to the timeline. Claude and Codex classify the same work as + * `collab_agent_tool_call`, so match them on every recognizable spelling. + */ +function isAcpTaskToolCall(toolCall: AcpToolCallState): boolean { + const rawInput = toolCall.data.rawInput; + if ( + typeof rawInput === "object" && + rawInput !== null && + "_toolName" in rawInput && + typeof rawInput._toolName === "string" + ) { + return rawInput._toolName.trim().toLowerCase() === "task"; + } + return typeof toolCall.title === "string" && /^task:/i.test(toolCall.title.trim()); +} + +function canonicalItemTypeFromAcpToolCall(toolCall: AcpToolCallState): ToolLifecycleItemType { + if (isAcpTaskToolCall(toolCall)) { + return "collab_agent_tool_call"; + } + return canonicalItemTypeFromAcpToolKind(toolCall.kind); +} + function runtimeItemStatusFromAcpToolStatus( status: AcpToolCallState["status"], ): "inProgress" | "completed" | "failed" | undefined { @@ -177,7 +205,7 @@ export function makeAcpToolCallEvent(input: { turnId: input.turnId, itemId: RuntimeItemId.make(input.toolCall.toolCallId), payload: { - itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), + itemType: canonicalItemTypeFromAcpToolCall(input.toolCall), ...(runtimeStatus ? { status: runtimeStatus } : {}), ...(input.toolCall.title ? { title: input.toolCall.title } : {}), ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), From bb264cad19ae19e295774a31cda182afa20e1541 Mon Sep 17 00:00:00 2001 From: NoahLinckeScout Date: Thu, 27 Aug 2026 16:02:59 -0700 Subject: [PATCH 2/4] fix(mobile): render collab agent tool calls as agent work, not generic tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #8443 noted that reclassified ACP Task launches were still invisible on mobile: the work-log icon derivation filed beside under the same hammer, the only presentation branch for either type. Right — the server change gave web bot styling and agent grouping, and mobile still showed anonymous tool noise. The icon now reads the collab type (or a task id) as agent work, matching how the web timeline classifies the same rows as . Other tool kinds keep their existing icons. Verified: apps/mobile suite 112 files passed, 765 tests passed. Server ACP classification tests still pass. Typecheck exit 0. The new test fails against the pre-fix hammer grouping. --- apps/mobile/src/lib/threadActivity.test.ts | 44 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 8 ++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e2943ebc1a0d..699d157837c7 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -809,6 +809,50 @@ describe("buildThreadFeed", () => { }); describe("quiet timeline: nested agents", () => { + it("renders a collab agent tool call as agent work, not generic tool noise", () => { + // The server's ACP Task-tool reclassification means Cursor/Grok subagent + // launches arrive with this itemType. Grouping them with + // `dynamic_tool_call`'s hammer is how invisible delegated work stays + // invisible on mobile: every other surface reads this type as an agent. + const turnId = TurnId.make("turn-collab-agent"); + const thread = makeThread({ + id: ThreadId.make("thread-collab-agent"), + projectId: ProjectId.make("project-1"), + title: "Collab agent work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("collab-agent-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Task: Subagent task", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "Task: Subagent task", + itemType: "collab_agent_tool_call", + status: "completed", + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities[0]?.icon).toBe("agent"); + }); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9e0cb64ae8b3..9c03a3dabc7e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -650,9 +650,11 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.itemType === "web_search") return "globe"; if (entry.itemType === "image_view") return "eye"; if (entry.itemType === "mcp_tool_call") return "wrench"; - if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { - return "hammer"; - } + // The ACP Task-tool reclassification now files delegated subagent launches + // here; they are agent work, not another anonymous tool row. Matches the + // web timeline, which reads the same type as `agent-tool`. + if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent"; + if (entry.itemType === "dynamic_tool_call") return "hammer"; if (entry.tone === "error") return "alert"; if (entry.tone === "thinking") return "agent"; if (entry.tone === "info") return "check"; From 6b6453439d9ef3c756b3782a1e4fbb2276b4ccae Mon Sep 17 00:00:00 2001 From: NoahLinckeScout Date: Thu, 27 Aug 2026 16:03:39 -0700 Subject: [PATCH 3/4] fix(mobile): render collab agent tool calls as agent work, not generic tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #8443 noted that reclassified ACP Task launches were still invisible on mobile: the work-log icon derivation filed collab_agent_tool_call beside dynamic_tool_call under the same hammer, the only presentation branch for either type. Right — the server change gave web bot styling and agent grouping, and mobile still showed anonymous tool noise. The icon now reads the collab type (or a task id) as agent work, matching how the web timeline classifies the same rows as agent-tool. Other tool kinds keep their existing icons. Verified: apps/mobile suite 112 files passed, 765 tests passed. Server ACP classification tests still pass. Typecheck exit 0. The new test fails against the pre-fix hammer grouping. From 24764ff5670fcf32a5f509e5ec3f321b78acb294 Mon Sep 17 00:00:00 2001 From: NoahLinckeScout Date: Thu, 27 Aug 2026 16:14:54 -0700 Subject: [PATCH 4/4] fix(mobile): keep the failure chrome on failed tasks that carry a taskId Cursor Bugbot on the previous commit: the collab-agent branch ran before the error-tone branch, so a failed task.completed or terminal task.updated with a taskId rendered as agent instead of alert. Mobile has one icon where web overlays failure after selection, so nested-agent failures lost their only destructive signal. The error tone now wins above the agent branch. Added the failing-then-passing test: a failed collab_agent_tool_call with a taskId keeps the alert icon. Verified: apps/mobile suite 112 files passed, 766 tests passed. --- apps/mobile/src/lib/threadActivity.test.ts | 45 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 5 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 699d157837c7..afa8f70eeccf 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -853,6 +853,51 @@ describe("quiet timeline: nested agents", () => { expect(group.activities[0]?.icon).toBe("agent"); }); + it("keeps the failure chrome on a failed task that carries a taskId", () => { + // The `entry.taskId` collab-agent branch must not swallow error tone: a + // failed `task.completed` is still a failure, and `alert` is the only + // mobile signal that renders one. Web overlays failure after icon + // selection; mobile has one icon for both. + const turnId = TurnId.make("turn-collab-agent-failed"); + const thread = makeThread({ + id: ThreadId.make("thread-collab-agent-failed"), + projectId: ProjectId.make("project-1"), + title: "Failed collab agent work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("collab-agent-failed"), + kind: "tool.completed", + tone: "error", + summary: "Task: Subagent task", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "Task: Subagent task", + itemType: "collab_agent_tool_call", + taskId: "subagent-1", + status: "failed", + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities[0]?.icon).toBe("alert"); + }); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9c03a3dabc7e..462094e12d37 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -650,12 +650,13 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.itemType === "web_search") return "globe"; if (entry.itemType === "image_view") return "eye"; if (entry.itemType === "mcp_tool_call") return "wrench"; + if (entry.tone === "error") return "alert"; // The ACP Task-tool reclassification now files delegated subagent launches // here; they are agent work, not another anonymous tool row. Matches the - // web timeline, which reads the same type as `agent-tool`. + // web timeline, which reads the same type as `agent-tool`. The error tone + // wins above so a failed task still loses its icon to the alert chrome. if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent"; if (entry.itemType === "dynamic_tool_call") return "hammer"; - if (entry.tone === "error") return "alert"; if (entry.tone === "thinking") return "agent"; if (entry.tone === "info") return "check"; return "zap";