diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e2943ebc1a0d..afa8f70eeccf 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -809,6 +809,95 @@ 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 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 9e0cb64ae8b3..462094e12d37 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -650,10 +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.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { - return "hammer"; - } 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`. 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 === "thinking") return "agent"; if (entry.tone === "info") return "check"; return "zap"; 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 } : {}),