1import type { T3McpToolSummaryAction } from "@t3tools/shared/t3McpToolPresentation";
2
3export interface T3ToolSummaryCall {
4 readonly input: unknown;
5 readonly output: unknown;
6 readonly outcome: "completed" | "failed" | "unfinished";
7}
8
9function asRecord(value: unknown): Record<string, unknown> | undefined {
10 return value !== null && typeof value === "object" && !Array.isArray(value)
11 ? (value as Record<string, unknown>)
12 : undefined;
13}
14
15function id(value: unknown): string | undefined {
16 return typeof value === "string" && value.trim() !== "" ? value : undefined;
17}
18
19interface ToolResult {
20 readonly data?: Record<string, unknown>;
21 readonly failed: boolean;
22}
23
24/** Reads the structured/JSON MCP result envelopes retained by the provider adapters. */
25function readResult(value: unknown, depth = 0): ToolResult {
26 if (depth > 4) return { failed: false };
27 if (typeof value === "string") {
28 try {
29 return readResult(JSON.parse(value), depth + 1);
30 } catch {
31 return { failed: false };
32 }
33 }
34 if (Array.isArray(value)) {
35 let data: ToolResult["data"];
36 let failed = false;
37 for (const block of value) {
38 const record = asRecord(block);
39 const text = record?.text ?? asRecord(record?.content)?.text;
40 const result = readResult(asRecord(text)?.text ?? text, depth + 1);
41 data ??= result.data;
42 failed ||= result.failed;
43 }
44 return { ...(data ? { data } : {}), failed };
45 }
46 const record = asRecord(value);
47 if (!record) return { failed: false };
48 const failed =
49 record.isError === true ||
50 record.is_error === true ||
51 (typeof record._tag === "string" && /(?:Error|Failure)$/.test(record._tag)) ||
52 record.error != null;
53 const content = record.structuredContent ?? record.content;
54 if (content !== undefined) {
55 const result = readResult(content, depth + 1);
56 return { ...(result.data ? { data: result.data } : {}), failed: failed || result.failed };
57 }
58 return { data: record, failed };
59}
60
61function readInput(value: unknown): Record<string, unknown> | undefined {
62 const input = readResult(value).data;
63 // Cursor retains its MCP args envelope; other adapters retain the arguments directly.
64 return input && typeof input.toolName === "string" ? asRecord(input.args) : input;
65}
66
67/** MCP errors can be returned as data even when the provider completed the tool call. */
68export function t3ToolResultIndicatesFailure(output: unknown): boolean {
69 return readResult(output).failed;
70}
71
72function countEntities(ids: ReadonlyArray<string | undefined>): number {
73 return (
74 new Set(ids.filter((value) => value !== undefined)).size +
75 ids.filter((value) => value === undefined).length
76 );
77}
78
79function quantity(count: number, noun: string, plural = `${noun}s`): string {
80 return `${count} ${count === 1 ? noun : plural}`;
81}
82
83/** Counts successful effects separately from failed or unfinished tool calls. */
84export function summarizeT3ToolCalls(
85 action: T3McpToolSummaryAction,
86 calls: ReadonlyArray<T3ToolSummaryCall>,
87): { label: string; failedCount: number } {
88 const results = calls.map((call) => {
89 const result = readResult(call.output);
90 return {
91 input: readInput(call.input),
92 output: result.data,
93 outcome: result.failed ? ("failed" as const) : call.outcome,
94 };
95 });
96 const completed = results.filter((call) => call.outcome === "completed");
97 let failedCount = results.filter((call) => call.outcome === "failed").length;
98 const selected = completed.length > 0 ? completed : results;
99 const times = quantity(selected.length, "time");
100 const phrase = (past: string, infinitive: string, object: string) =>
101 `${completed.length > 0 ? past : `Tried to ${infinitive}`} ${object}`;
102 const entityIds = (key: string) =>
103 selected.map((call) => id(call.output?.[key]) ?? id(call.input?.[key]));
104 const projectIds = selected.map(
105 (call) => id(call.output?.id) ?? id(call.output?.projectId) ?? id(call.input?.projectId),
106 );
107 const threadIds = selected.map(
108 (call) =>
109 id(call.output?.threadId) ??
110 id(asRecord(call.output?.thread)?.threadId) ??
111 id(call.input?.threadId),
112 );
113 let label: string;
114 switch (action) {
115 case "thread-send": {
116 const messages = countEntities(selected.map((call) => id(call.output?.messageId)));
117 const targetsKnown = threadIds.every((value) => value !== undefined);
118 const threads = new Set(threadIds).size;
119 const object = targetsKnown
120 ? messages === threads && messages > 1
121 ? `messages to ${quantity(threads, "thread")}`
122 : `${quantity(messages, "message")} to ${quantity(threads, "thread")}`
123 : quantity(messages, "message");
124 label = phrase("Sent", "send", object);
125 break;
126 }
127 case "thread-create": {
128 const createdIds: string[] = [];
129 const resultsKnown =
130 completed.length > 0 &&
131 completed.every((call) => {
132 const threads = Array.isArray(call.output?.threads) ? call.output.threads : [call.output];
133 return threads.every((thread) => {
134 const record = asRecord(thread);
135 if (record?.status === "rolled_back") return true;
136 const threadId = id(record?.threadId);
137 if (!threadId) return false;
138 createdIds.push(threadId);
139 return true;
140 });
141 });
142 label = resultsKnown
143 ? `Created ${quantity(new Set(createdIds).size, "thread")}`
144 : `Requested thread creation ${times}`;
145 break;
146 }
147 case "delegate":
148 label = phrase("Delegated", "delegate", quantity(countEntities(entityIds("taskId")), "task"));
149 break;
150 case "thread-read":
151 case "thread-wait": {
152 const targets = threadIds.every((value) => value !== undefined)
153 ? quantity(new Set(threadIds).size, "thread")
154 : `threads ${times}`;
155 label =
156 action === "thread-read"
157 ? phrase("Read", "read", targets)
158 : phrase("Waited on", "wait on", targets);
159 break;
160 }
161 case "thread-list":
162 label = phrase("Listed", "list", `threads ${times}`);
163 break;
164 case "thread-interrupt":
165 label = phrase(
166 "Requested interrupts for",
167 "interrupt",
168 quantity(countEntities(threadIds), "thread"),
169 );
170 break;
171 case "workflow-stop":
172 label = phrase(
173 "Requested stops for",
174 "stop",
175 quantity(countEntities(entityIds("subagentId")), "workflow"),
176 );
177 break;
178 case "task-status":
179 label = phrase("Checked", "check", `task status ${times}`);
180 break;
181 case "task-cancel":
182 label = phrase(
183 "Requested cancellation of",
184 "cancel",
185 quantity(countEntities(entityIds("taskId")), "task"),
186 );
187 break;
188 case "schedule-create":
189 label = phrase(
190 "Scheduled",
191 "schedule",
192 quantity(countEntities(entityIds("scheduledTaskId")), "task"),
193 );
194 break;
195 case "schedule-list":
196 label = phrase("Listed", "list", `scheduled tasks ${times}`);
197 break;
198 case "schedule-update":
199 label = phrase(
200 "Updated",
201 "update",
202 quantity(countEntities(entityIds("scheduledTaskId")), "scheduled task"),
203 );
204 break;
205 case "schedule-delete":
206 // A successful delete can report deleted:false; it still represents a deletion request.
207 label = phrase(
208 "Requested deletion of",
209 "delete",
210 quantity(countEntities(entityIds("scheduledTaskId")), "scheduled task"),
211 );
212 break;
213 case "schedule-run":
214 label = phrase("Requested", "request", quantity(selected.length, "scheduled task run"));
215 break;
216 case "thread-configuration":
217 label = phrase("Checked", "check", `thread configuration ${times}`);
218 break;
219 case "thread-configure":
220 label = phrase("Set", "set", `thread model ${times}`);
221 break;
222 case "thread-fork":
223 label = phrase("Requested", "request", quantity(selected.length, "thread fork"));
224 break;
225 case "thread-merge":
226 label = phrase("Requested", "request", quantity(selected.length, "context merge"));
227 break;
228 case "thread-search":
229 label = phrase("Searched", "search", `threads ${times}`);
230 break;
231 case "thread-transfers":
232 label = phrase("Checked", "check", `thread transfers ${times}`);
233 break;
234 case "thread-organize":
235 label = phrase("Organized", "organize", `threads ${times}`);
236 break;
237 case "thread-update":
238 label = phrase("Updated", "update", quantity(countEntities(threadIds), "thread"));
239 break;
240 case "queue-list":
241 label = phrase("Listed", "list", `queued messages ${times}`);
242 break;
243 case "queue-read":
244 label = phrase(
245 "Read",
246 "read",
247 quantity(countEntities(entityIds("queuedRunId")), "queued message"),
248 );
249 break;
250 case "queue-edit":
251 label = phrase(
252 "Edited",
253 "edit",
254 quantity(countEntities(entityIds("queuedRunId")), "queued message"),
255 );
256 break;
257 case "queue-cancel":
258 label = phrase(
259 "Requested cancellation of",
260 "cancel",
261 quantity(countEntities(entityIds("queuedRunId")), "queued run"),
262 );
263 break;
264 case "queue-reorder":
265 label = phrase(
266 "Reordered",
267 "reorder",
268 quantity(countEntities(entityIds("queuedRunId")), "queued run"),
269 );
270 break;
271 case "queue-steer":
272 label = phrase(
273 "Requested steering with",
274 "steer with",
275 quantity(countEntities(entityIds("queuedRunId")), "queued message"),
276 );
277 break;
278 case "question-list":
279 label = phrase("Listed", "list", `pending questions ${times}`);
280 break;
281 case "question-read":
282 label = phrase(
283 "Read",
284 "read",
285 quantity(countEntities(entityIds("requestId")), "pending question request"),
286 );
287 break;
288 case "question-respond":
289 label = phrase(
290 "Answered",
291 "answer",
292 quantity(countEntities(entityIds("requestId")), "pending question request"),
293 );
294 break;
295 case "secret-request":
296 label = phrase("Asked for", "ask for", quantity(selected.length, "secret"));
297 break;
298 case "worktree-handoff":
299 label = phrase(
300 "Handed off to",
301 "hand off to",
302 quantity(countEntities(entityIds("worktreePath")), "worktree"),
303 );
304 break;
305 case "worktree-list":
306 label = phrase("Listed", "list", `workspace branches ${times}`);
307 break;
308 case "worktree-status":
309 label = phrase("Checked", "check", `worktree status ${times}`);
310 break;
311 case "project-list":
312 label = phrase("Listed", "list", `projects ${times}`);
313 break;
314 case "project-read":
315 label = phrase("Read", "read", quantity(countEntities(projectIds), "project"));
316 break;
317 case "project-create":
318 label = phrase("Registered", "register", quantity(countEntities(projectIds), "project"));
319 break;
320 case "project-update":
321 label = phrase("Updated", "update", quantity(countEntities(projectIds), "project"));
322 break;
323 case "project-delete":
324 label = phrase("Deleted", "delete", quantity(countEntities(projectIds), "project"));
325 break;
326 case "project-clone":
327 label = phrase(
328 "Cloned",
329 "clone",
330 quantity(countEntities(entityIds("cwd")), "repository", "repositories"),
331 );
332 break;
333 case "environment-read":
334 label = phrase("Checked", "check", `environment preferences ${times}`);
335 break;
336 case "environment-update":
337 label = phrase("Updated", "update", `environment preferences ${times}`);
338 break;
339 case "attachment-prepare":
340 label = phrase(
341 "Prepared",
342 "prepare",
343 quantity(countEntities(entityIds("attachmentId")), "attachment upload"),
344 );
345 break;
346 case "attachment-discard":
347 label = phrase(
348 "Discarded",
349 "discard",
350 quantity(countEntities(entityIds("attachmentId")), "pending attachment"),
351 );
352 break;
353 case "attachment-send": {
354 // A message can contain several attachments. Count retries by message identity.
355 const messages = new Map<string, (typeof selected)[number]>();
356 selected.forEach((call, index) =>
357 messages.set(id(call.output?.messageId) ?? `call-${index}`, call),
358 );
359 const attachmentCount = [...messages.values()].reduce(
360 (count, call) =>
361 count + (Array.isArray(call.input?.attachments) ? call.input.attachments.length : 0),
362 0,
363 );
364 const countsKnown = [...messages.values()].every(
365 (call) => Array.isArray(call.input?.attachments) && call.input.attachments.length > 0,
366 );
367 const targets = threadIds.every((value) => value !== undefined)
368 ? ` to ${quantity(new Set(threadIds).size, "thread")}`
369 : "";
370 label = phrase(
371 "Sent",
372 "send",
373 countsKnown
374 ? `${quantity(attachmentCount, "attachment")}${targets}`
375 : `attachments${targets} ${times}`,
376 );
377 break;
378 }
379 case "link-pr":
380 label = phrase("Linked", "link", quantity(selected.length, "pull request"));
381 break;
382 case "unlink-pr":
383 label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request"));
384 break;
385 case "watch-pr":
386 label = phrase("Watching", "watch", quantity(selected.length, "pull request"));
387 break;
388 case "unwatch-pr":
389 label = phrase(
390 "Stopped watching",
391 "stop watching",
392 quantity(selected.length, "pull request"),
393 );
394 break;
395 case "list-prs":
396 label = phrase(
397 "Checked",
398 "check",
399 `linked pull requests${selected.length === 1 ? "" : ` ${times}`}`,
400 );
401 break;
402 case "write-pr-guides": {
403 // One call writes many guides, and reports a refusal per guide rather than failing.
404 const outcomes = selected.flatMap((call) =>
405 Array.isArray(call.output?.guides)
406 ? call.output.guides.map((guide) => asRecord(guide)?.outcome)
407 : [],
408 );
409 const started = outcomes.filter((outcome) => outcome !== "failed").length;
410 const failed = outcomes.length - started;
411 failedCount += failed;
412 if (outcomes.length > 0 && started === 0) {
413 label = `Could not start ${quantity(failed, "review guide")}`;
414 } else {
415 label = phrase(
416 "Started",
417 "start",
418 outcomes.length === 0
419 ? `review guides ${times}`
420 : `${quantity(started, "review guide")}${failed > 0 ? `, ${failed} failed` : ""}`,
421 );
422 }
423 break;
424 }
425 case "read-pr-guide":
426 label = phrase("Read", "read", quantity(selected.length, "review guide"));
427 break;
428 case "browser":
429 label = phrase("Used", "use", `browser ${times}`);
430 break;
431 case "device":
432 label = phrase("Used", "use", `device controls ${times}`);
433 break;
434 case "html-preview":
435 label = phrase("Previewed", "preview", quantity(selected.length, "HTML page"));
436 break;
437 case "html-render":
438 label = phrase("Rendered", "render", quantity(selected.length, "HTML page"));
439 break;
440 case "capabilities":
441 label = phrase("Checked", "check", `orchestration capabilities ${times}`);
442 break;
443 }
444 return { label, failedCount };
445}