Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
NoahLinckeScout marked this conversation as resolved.
if (entry.itemType === "dynamic_tool_call") return "hammer";
if (entry.tone === "thinking") return "agent";
if (entry.tone === "info") return "check";
return "zap";
Expand Down
79 changes: 79 additions & 0 deletions apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});
});
30 changes: 29 additions & 1 deletion apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
NoahLinckeScout marked this conversation as resolved.
}
return canonicalItemTypeFromAcpToolKind(toolCall.kind);
}

function runtimeItemStatusFromAcpToolStatus(
status: AcpToolCallState["status"],
): "inProgress" | "completed" | "failed" | undefined {
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading