Skip to content

Commit 8998876

Browse files
authored
feat: enrich collab sub-agent metadata rendering (#474)
1 parent 212dbd3 commit 8998876

7 files changed

Lines changed: 509 additions & 25 deletions

File tree

src/features/messages/utils/messageRenderUtils.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,27 @@ describe("messageRenderUtils", () => {
4141
it("classifies camelCase inProgress as processing", () => {
4242
expect(statusToneFromText("inProgress")).toBe("processing");
4343
});
44+
45+
it("renders collab tool calls with nickname and role", () => {
46+
const summary = buildToolSummary(
47+
makeToolItem({
48+
toolType: "collabToolCall",
49+
title: "Collab: wait",
50+
detail: "From thread-parent → thread-child",
51+
status: "completed",
52+
output: "Robie [explorer]: completed",
53+
collabReceivers: [
54+
{
55+
threadId: "thread-child",
56+
nickname: "Robie",
57+
role: "explorer",
58+
},
59+
],
60+
}),
61+
"",
62+
);
63+
expect(summary.label).toBe("waited for");
64+
expect(summary.value).toBe("Robie [explorer]");
65+
expect(summary.output).toContain("Robie [explorer]: completed");
66+
});
4467
});

src/features/messages/utils/messageRenderUtils.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,64 @@ function firstStringField(
7777
return "";
7878
}
7979

80+
function formatCollabAgentLabel(agent: {
81+
threadId: string;
82+
nickname?: string;
83+
role?: string;
84+
}) {
85+
const nickname = agent.nickname?.trim();
86+
const role = agent.role?.trim();
87+
if (nickname && role) {
88+
return `${nickname} [${role}]`;
89+
}
90+
if (nickname) {
91+
return nickname;
92+
}
93+
if (role) {
94+
return `${agent.threadId} [${role}]`;
95+
}
96+
return agent.threadId;
97+
}
98+
99+
function summarizeCollabLabel(title: string, status?: string) {
100+
const tool = title.replace(/^collab:\s*/i, "").trim().toLowerCase();
101+
const tone = statusToneFromText(status);
102+
if (tool.includes("wait")) {
103+
return tone === "processing" ? "waiting for" : "waited for";
104+
}
105+
if (tool.includes("resume")) {
106+
return tone === "processing" ? "resuming" : "resumed";
107+
}
108+
if (tool.includes("close")) {
109+
return tone === "processing" ? "closing" : "closed";
110+
}
111+
if (tool.includes("spawn")) {
112+
return tone === "processing" ? "spawning" : "spawned";
113+
}
114+
if (tool.includes("send") || tool.includes("interaction")) {
115+
return tone === "processing" ? "sending to" : "sent to";
116+
}
117+
return "sub-agent";
118+
}
119+
120+
function summarizeCollabReceiver(
121+
item: Extract<ConversationItem, { kind: "tool" }>,
122+
) {
123+
const receivers =
124+
item.collabReceivers && item.collabReceivers.length > 0
125+
? item.collabReceivers
126+
: item.collabReceiver
127+
? [item.collabReceiver]
128+
: [];
129+
if (receivers.length === 0) {
130+
return item.title || "";
131+
}
132+
if (receivers.length === 1) {
133+
return formatCollabAgentLabel(receivers[0]);
134+
}
135+
return `${formatCollabAgentLabel(receivers[0])} +${receivers.length - 1}`;
136+
}
137+
80138
export function toolNameFromTitle(title: string) {
81139
if (!title.toLowerCase().startsWith("tool:")) {
82140
return "";
@@ -302,6 +360,15 @@ export function buildToolSummary(
302360
};
303361
}
304362

363+
if (item.toolType === "collabToolCall") {
364+
return {
365+
label: summarizeCollabLabel(item.title, item.status),
366+
value: summarizeCollabReceiver(item),
367+
detail: item.detail || "",
368+
output: item.output || "",
369+
};
370+
}
371+
305372
if (item.toolType === "mcpToolCall") {
306373
const toolName = toolNameFromTitle(item.title);
307374
const args = parseToolArgs(item.detail);

src/features/threads/hooks/useThreadLinking.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,49 @@ type UseThreadLinkingOptions = {
99
onSubagentThreadDetected?: (workspaceId: string, threadId: string) => void;
1010
};
1111

12+
function normalizeThreadId(value: unknown) {
13+
return asString(value).trim();
14+
}
15+
16+
function normalizeThreadIdsFromAgentRefs(value: unknown) {
17+
if (!Array.isArray(value)) {
18+
return [];
19+
}
20+
return value
21+
.map((entry) => {
22+
if (!entry || typeof entry !== "object") {
23+
return "";
24+
}
25+
const record = entry as Record<string, unknown>;
26+
return normalizeThreadId(record.threadId ?? record.thread_id ?? record.id);
27+
})
28+
.filter(Boolean);
29+
}
30+
31+
function normalizeThreadIdsFromAgentStatuses(value: unknown) {
32+
if (!Array.isArray(value)) {
33+
return [];
34+
}
35+
return value
36+
.map((entry) => {
37+
if (!entry || typeof entry !== "object") {
38+
return "";
39+
}
40+
const record = entry as Record<string, unknown>;
41+
return normalizeThreadId(record.threadId ?? record.thread_id ?? record.id);
42+
})
43+
.filter(Boolean);
44+
}
45+
46+
function normalizeThreadIdsFromStatusMap(value: unknown) {
47+
if (!value || typeof value !== "object" || Array.isArray(value)) {
48+
return [];
49+
}
50+
return Object.keys(value as Record<string, unknown>)
51+
.map((key) => normalizeThreadId(key))
52+
.filter(Boolean);
53+
}
54+
1255
export function useThreadLinking({
1356
dispatch,
1457
threadParentById,
@@ -70,11 +113,18 @@ export function useThreadLinking({
70113
if (!parentId) {
71114
return;
72115
}
73-
const receivers = [
74-
...normalizeStringList(item.receiverThreadId ?? item.receiver_thread_id),
75-
...normalizeStringList(item.receiverThreadIds ?? item.receiver_thread_ids),
76-
...normalizeStringList(item.newThreadId ?? item.new_thread_id),
77-
];
116+
const receivers = Array.from(
117+
new Set([
118+
...normalizeStringList(item.receiverThreadId ?? item.receiver_thread_id),
119+
...normalizeStringList(item.receiverThreadIds ?? item.receiver_thread_ids),
120+
...normalizeStringList(item.newThreadId ?? item.new_thread_id),
121+
...normalizeThreadIdsFromAgentRefs(item.receiverAgents ?? item.receiver_agents),
122+
...normalizeThreadIdsFromAgentStatuses(
123+
item.agentStatuses ?? item.agent_statuses,
124+
),
125+
...normalizeThreadIdsFromStatusMap(item.statuses),
126+
]),
127+
);
78128
updateThreadParent(parentId, receivers);
79129
receivers.forEach((receiver) => {
80130
if (!receiver) {

src/features/threads/hooks/useThreads.integration.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,37 @@ describe("useThreads UX integration", () => {
11211121
expect(result.current.isSubagentThread("ws-1", "thread-child-live-collab")).toBe(true);
11221122
});
11231123

1124+
it("classifies collab receivers from receiver_agents metadata", () => {
1125+
const { result } = renderHook(() =>
1126+
useThreads({
1127+
activeWorkspace: workspace,
1128+
onWorkspaceConnected: vi.fn(),
1129+
}),
1130+
);
1131+
1132+
act(() => {
1133+
handlers?.onItemCompleted?.("ws-1", "thread-parent-live", {
1134+
type: "collabToolCall",
1135+
id: "item-collab-receiver-agents",
1136+
sender_thread_id: "thread-parent-live",
1137+
receiver_agents: [
1138+
{
1139+
thread_id: "thread-child-live-agent-ref",
1140+
agent_nickname: "Robie",
1141+
agent_role: "explorer",
1142+
},
1143+
],
1144+
});
1145+
});
1146+
1147+
expect(result.current.threadParentById["thread-child-live-agent-ref"]).toBe(
1148+
"thread-parent-live",
1149+
);
1150+
expect(result.current.isSubagentThread("ws-1", "thread-child-live-agent-ref")).toBe(
1151+
true,
1152+
);
1153+
});
1154+
11241155
it("cascades archive to subagent descendants when parent archived", async () => {
11251156
const { result } = renderHook(() =>
11261157
useThreads({

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ export type Message = {
7171
text: string;
7272
};
7373

74+
export type CollabAgentRef = {
75+
threadId: string;
76+
nickname?: string;
77+
role?: string;
78+
};
79+
80+
export type CollabAgentStatus = CollabAgentRef & {
81+
status: string;
82+
};
83+
7484
export type ConversationItem =
7585
| {
7686
id: string;
@@ -98,6 +108,10 @@ export type ConversationItem =
98108
output?: string;
99109
durationMs?: number | null;
100110
changes?: { path: string; kind?: string; diff?: string }[];
111+
collabSender?: CollabAgentRef;
112+
collabReceiver?: CollabAgentRef;
113+
collabReceivers?: CollabAgentRef[];
114+
collabStatuses?: CollabAgentStatus[];
101115
};
102116

103117
export type ThreadSummary = {

src/utils/threadItems.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,11 +682,65 @@ describe("threadItems", () => {
682682
if (item && item.kind === "tool") {
683683
expect(item.title).toBe("Collab: handoff");
684684
expect(item.detail).toContain("From thread-a");
685-
expect(item.detail).toContain("thread-b, thread-c");
685+
expect(item.detail).toContain("thread-b");
686+
expect(item.detail).toContain("thread-c");
686687
expect(item.output).toBe("Coordinate work\n\nagent-1: running");
687688
}
688689
});
689690

691+
it("captures rich collab metadata from receiver_agents and agent_statuses", () => {
692+
const item = buildConversationItem({
693+
type: "collabToolCall",
694+
id: "collab-rich-1",
695+
tool: "wait",
696+
status: "completed",
697+
sender_thread_id: "thread-parent",
698+
receiver_agents: [
699+
{
700+
thread_id: "thread-child-1",
701+
agent_nickname: "Robie",
702+
agent_role: "explorer",
703+
},
704+
],
705+
agent_statuses: [
706+
{
707+
thread_id: "thread-child-1",
708+
status: "completed",
709+
agent_nickname: "Robie",
710+
agent_role: "explorer",
711+
},
712+
],
713+
prompt: "Wait for workers",
714+
});
715+
716+
expect(item).not.toBeNull();
717+
if (item && item.kind === "tool") {
718+
expect(item.collabSender).toEqual({ threadId: "thread-parent" });
719+
expect(item.collabReceiver).toEqual({
720+
threadId: "thread-child-1",
721+
nickname: "Robie",
722+
role: "explorer",
723+
});
724+
expect(item.collabReceivers).toEqual([
725+
{
726+
threadId: "thread-child-1",
727+
nickname: "Robie",
728+
role: "explorer",
729+
},
730+
]);
731+
expect(item.collabStatuses).toEqual([
732+
{
733+
threadId: "thread-child-1",
734+
nickname: "Robie",
735+
role: "explorer",
736+
status: "completed",
737+
},
738+
]);
739+
expect(item.detail).toContain("Robie [explorer]");
740+
expect(item.output).toContain("Robie [explorer]: completed");
741+
}
742+
});
743+
690744
it("builds context compaction items", () => {
691745
const item = buildConversationItem({
692746
type: "contextCompaction",

0 commit comments

Comments
 (0)