Skip to content
Source
Sign inSign up
Open History to see the last commit

t3ToolSummary.ts

483 lines16.2 KB

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