1import {
2 isOrchestrationV2WorkActive,
3 isToolLifecycleItemType,
4 type AssetResource,
5 type RuntimeItemStatus,
6 type ToolActivitySource,
7 type ToolActivitySurface,
8 type ToolActivityIcon,
9 type OrchestrationV2TurnItem,
10 type ThreadId,
11} from "@t3tools/contracts";
12import type { ToolCallVisibility } from "@t3tools/contracts/settings";
13import {
14 resolveT3McpSummaryAction,
15 resolveT3McpToolDefinition,
16 type T3McpToolDefinition,
17 type T3McpToolSummaryAction,
18} from "@t3tools/shared/t3McpToolPresentation";
19import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
20import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
21import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
22import { isWorkspaceImagePreviewPath } from "@t3tools/shared/mediaPreview";
23import { formatTokens } from "@t3tools/shared/usageFormat";
24import { classifyToolActivity } from "@t3tools/shared/toolActivity";
25import { toolOutputIndicatesFailure } from "@t3tools/shared/toolOutput";
26
27import {
28 summarizeT3ToolCalls,
29 t3ToolResultIndicatesFailure,
30 type T3ToolSummaryCall,
31} from "@t3tools/client-runtime/t3ToolSummary";
32
33export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped" | "idle";
34
35export function toolItemForDisplay(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
36 switch (item.type) {
37 case "command_execution":
38 case "dynamic_tool": {
39 const { output: _output, ...displayItem } = item;
40 return displayItem;
41 }
42 case "file_change": {
43 const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;
44 // A failed edit's diffStr holds the provider's error, not a diff.
45 return item.status === "failed" && diffStr?.trim()
46 ? { ...displayItem, diffStr }
47 : displayItem;
48 }
49 default:
50 return item;
51 }
52}
53
54type CompactionLabelItem = Pick<
55 Extract<OrchestrationV2TurnItem, { type: "compaction" }>,
56 "status" | "beforeTokenCount" | "afterTokenCount"
57>;
58
59export function contextCompactionTokenCounts(
60 item: CompactionLabelItem,
61): { readonly before: string; readonly after: string } | null {
62 if (
63 item.status === "running" ||
64 item.beforeTokenCount === undefined ||
65 item.afterTokenCount === undefined
66 ) {
67 return null;
68 }
69 return { before: formatTokens(item.beforeTokenCount), after: formatTokens(item.afterTokenCount) };
70}
71
72export function contextCompactionLabel(item: CompactionLabelItem): string {
73 if (item.status === "failed") return "Context compaction failed";
74 if (item.status === "cancelled" || item.status === "interrupted")
75 return "Context compaction stopped";
76 if (isOrchestrationV2WorkActive(item.status)) return "Compacting context";
77 const tokens = contextCompactionTokenCounts(item);
78 return tokens
79 ? `Context compacted ${tokens.before} → ${tokens.after} tokens`
80 : "Context compacted";
81}
82
83export interface WorkLogPresentationEntry {
84 readonly questionAnswer?: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
85 readonly id: string;
86 readonly createdAt: string;
87 readonly label: string;
88 readonly tone: "thinking" | "tool" | "info" | "error";
89 readonly command?: string;
90 readonly rawCommand?: string;
91 readonly detail?: string;
92 readonly viewedImagePath?: string;
93 readonly changedFiles?: ReadonlyArray<string>;
94 readonly toolTitle?: string;
95 readonly toolData?: unknown;
96 readonly requestKind?: string;
97 readonly itemType?: OrchestrationV2TurnItem["type"];
98 readonly toolLifecycleStatus?: WorkLogToolLifecycleStatus;
99 readonly structuredPayload?: OrchestrationV2TurnItem;
100 readonly toolSource?: ToolActivitySource;
101 readonly toolSurface?: ToolActivitySurface;
102 readonly toolIcon?: ToolActivityIcon;
103}
104
105export type ToolGroupAction =
106 | "link-pr"
107 | "unlink-pr"
108 | "list-prs"
109 | "watch-pr"
110 | "unwatch-pr"
111 | "read"
112 | "edit"
113 | "command"
114 | "thread-create"
115 | "browser"
116 | "device"
117 | "code-search"
118 | "search"
119 | "other"
120 | "update";
121
122export type ToolGroupSummaryKind =
123 | "pull-request"
124 | ToolGroupAction
125 | "dynamic-tool"
126 | "reasoning"
127 | "agent-tool"
128 | "tone-tool"
129 | "mixed";
130
131export function normalizeCompactToolLabel(value: string): string {
132 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
133}
134
135function workEntryToolName(
136 entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "structuredPayload">,
137): string | undefined {
138 const item = entry.structuredPayload;
139 if (item?.type === "dynamic_tool" && item.toolName) return item.toolName;
140 const data = asRecord(entry.toolData);
141 if (typeof data?.server === "string" && typeof data.tool === "string") {
142 return `${data.server}.${data.tool}`;
143 }
144 if (typeof data?.toolName === "string") return data.toolName;
145 return resolveT3McpToolDefinition(entry.toolTitle) ? entry.toolTitle : entry.label;
146}
147
148function workEntryToolInput(
149 entry: Pick<WorkLogPresentationEntry, "toolData" | "structuredPayload">,
150): unknown {
151 const item = entry.structuredPayload;
152 const data = asRecord(entry.toolData);
153 return item?.type === "dynamic_tool"
154 ? item.input
155 : (data?.arguments ?? data?.input ?? data?.rawInput);
156}
157
158function workEntryToolOutput(
159 entry: Pick<WorkLogPresentationEntry, "toolData" | "structuredPayload">,
160): unknown {
161 const item = entry.structuredPayload;
162 const data = asRecord(entry.toolData);
163 return item?.type === "dynamic_tool"
164 ? item.output
165 : (data?.output ?? data?.result ?? data?.rawOutput ?? data?.content);
166}
167
168function resolveT3McpToolPresentation(
169 definition: T3McpToolDefinition | null,
170 status: string | undefined,
171 data?: unknown,
172) {
173 if (!definition) return null;
174 const [action, running, completed, detail] = definition.labels;
175 const verb =
176 status === "inProgress"
177 ? running
178 : status === "completed"
179 ? completed
180 : status === "failed"
181 ? `Failed to ${action.toLowerCase()}`
182 : status === "declined"
183 ? `Declined to ${action.toLowerCase()}`
184 : status === "stopped"
185 ? `Stopped ${running.toLowerCase()}`
186 : running;
187
188 const actionKind =
189 definition.summaryAction === "link-pr" ||
190 definition.summaryAction === "unlink-pr" ||
191 definition.summaryAction === "list-prs" ||
192 definition.summaryAction === "watch-pr" ||
193 definition.summaryAction === "unwatch-pr"
194 ? definition.summaryAction
195 : undefined;
196 const payload = asRecord(data);
197 const input =
198 asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput);
199 const urlTarget =
200 typeof input?.url === "string" ? sourceControlClients.parseChangeRequestUrl(input.url) : null;
201 const number = urlTarget?.link.number ?? input?.number;
202 const { shortLabel } = (urlTarget?.definition ?? sourceControlClients.get(undefined))
203 .changeRequest;
204 const target =
205 actionKind !== undefined &&
206 actionKind !== "list-prs" &&
207 typeof number === "number" &&
208 Number.isSafeInteger(number) &&
209 number > 0
210 ? `${shortLabel} #${number}`
211 : detail;
212 return {
213 displayName: `${verb} ${target}`,
214 icon: definition.icon,
215 ...(actionKind === undefined ? {} : { action: actionKind }),
216 };
217}
218
219export function liveActivityToolStatus(status: string | undefined, presentTense: boolean) {
220 if (status === "failed" || status === "declined" || status === "stopped" || status === "idle") {
221 return status;
222 }
223 if (presentTense || status === "inProgress") return "inProgress";
224 return "completed";
225}
226
227export function resolveWorkEntryToolPresentation(
228 entry: Pick<
229 WorkLogPresentationEntry,
230 "label" | "toolTitle" | "toolData" | "toolLifecycleStatus" | "structuredPayload"
231 >,
232 fallbackStatus?: "inProgress" | "completed",
233) {
234 const definition = resolveT3McpToolDefinition(
235 workEntryToolName(entry),
236 workEntryToolInput(entry),
237 );
238 const status = entry.toolLifecycleStatus ?? fallbackStatus;
239 return resolveT3McpToolPresentation(
240 definition,
241 definition && t3ToolResultIndicatesFailure(workEntryToolOutput(entry)) ? "failed" : status,
242 entry.toolData,
243 );
244}
245
246function asRecord(value: unknown): Record<string, unknown> | null {
247 return value !== null && typeof value === "object" && !Array.isArray(value)
248 ? (value as Record<string, unknown>)
249 : null;
250}
251
252function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
253 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
254 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
255 if (entry.requestKind !== undefined) return true;
256 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
257}
258
259function workEntryIndicatesToolFailureFromOutput(
260 entry: WorkLogPresentationEntry,
261 includeCommand: boolean,
262): boolean {
263 if (
264 entry.tone === "error" ||
265 entry.toolLifecycleStatus === "failed" ||
266 entry.toolLifecycleStatus === "declined"
267 ) {
268 return true;
269 }
270 // Reasoning detail is the model's own prose (often tens of KB), not tool
271 // output: scanning it is slow on long threads and misreads phrases like
272 // "that exit code 2 is expected" as a failed tool.
273 if (!workLogEntryIsToolLike(entry) || entry.itemType === "reasoning") return false;
274 if (
275 resolveT3McpToolDefinition(workEntryToolName(entry)) &&
276 t3ToolResultIndicatesFailure(workEntryToolOutput(entry))
277 ) {
278 return true;
279 }
280 const item = entry.structuredPayload;
281 if (item?.type === "command_execution") {
282 // The exit code outranks text in the output or the command itself.
283 if (item.exitCode !== undefined) return item.exitCode !== 0;
284 if (item.outputIndicatesFailure) return true;
285 if (item.output && toolOutputIndicatesFailure(item.output.slice(0, 32_768))) {
286 return true;
287 }
288 }
289 const output = includeCommand
290 ? [entry.detail, entry.command].filter(Boolean).join("\n")
291 : (entry.detail ?? "");
292 return output.length > 0 && toolOutputIndicatesFailure(output);
293}
294
295// Rows are re-derived on every timeline update but entries are reused while
296// their item is unchanged, so these per-entry checks are computed once.
297const entryChecks = new WeakMap<
298 WorkLogPresentationEntry,
299 { failure?: boolean; displayFailure?: boolean; action?: ToolGroupAction }
300>();
301
302function checksFor(entry: WorkLogPresentationEntry) {
303 let checks = entryChecks.get(entry);
304 if (!checks) {
305 checks = {};
306 entryChecks.set(entry, checks);
307 }
308 return checks;
309}
310
311export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean {
312 return (checksFor(entry).failure ??= workEntryIndicatesToolFailureFromOutput(entry, true));
313}
314
315export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean {
316 return (checksFor(entry).displayFailure ??= workEntryIndicatesToolFailureFromOutput(
317 entry,
318 false,
319 ));
320}
321
322const HIDEABLE_TOOL_CALL_ITEM_TYPES: ReadonlySet<WorkLogPresentationEntry["itemType"]> = new Set([
323 "command_execution",
324 "file_change",
325 "file_search",
326 "web_search",
327 "dynamic_tool",
328]);
329
330export function workEntryHiddenByToolCallVisibility(
331 entry: WorkLogPresentationEntry,
332 visibility: ToolCallVisibility,
333): boolean {
334 if (visibility === "showAll" || !HIDEABLE_TOOL_CALL_ITEM_TYPES.has(entry.itemType)) return false;
335 if (visibility !== "hideSuccessful") return true;
336 return entry.toolLifecycleStatus === "completed" && !workEntryDisplayIndicatesToolFailure(entry);
337}
338
339function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
340 return (
341 entry.itemType === "file_search" ||
342 (entry.itemType === "web_search" &&
343 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)))
344 );
345}
346
347export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
348 return (checksFor(entry).action ??= computeToolGroupAction(entry));
349}
350
351function computeToolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
352 if (entry.itemType === "thread_created") return "thread-create";
353 const presentation = resolveWorkEntryToolPresentation(entry);
354 if (presentation?.action !== undefined) return presentation.action;
355 if (presentation?.icon === "browser") return "browser";
356 if (presentation?.icon === "device") return "device";
357 if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read";
358 if (entry.itemType === "approval_request" || entry.itemType === "user_input_request") {
359 return workLogEntryIsToolLike(entry) ? "other" : "update";
360 }
361 const data = asRecord(entry.toolData) ?? {};
362 const toolName =
363 entry.structuredPayload?.type === "dynamic_tool"
364 ? entry.structuredPayload.toolName
365 : typeof data.toolName === "string"
366 ? data.toolName
367 : entry.toolTitle;
368 const classified = classifyToolActivity({
369 itemType:
370 entry.itemType === "command_execution" ||
371 entry.itemType === "file_change" ||
372 entry.itemType === "web_search"
373 ? entry.itemType
374 : entry.itemType === "dynamic_tool"
375 ? "dynamic_tool_call"
376 : undefined,
377 title: entry.toolTitle ?? entry.label,
378 data: {
379 ...data,
380 ...(toolName ? { toolName } : {}),
381 },
382 });
383 if (classified === "read") return "read";
384 if (classified === "file_change" || entry.itemType === "file_change") return "edit";
385 if (classified === "command" || entry.itemType === "command_execution" || entry.command) {
386 return "command";
387 }
388 if (classified === "search") {
389 return entry.itemType === "web_search" && !workLogEntryIsLocalCodeSearch(entry)
390 ? "search"
391 : "code-search";
392 }
393 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
394 if (entry.itemType === "web_search") return "search";
395 if ((entry.changedFiles?.length ?? 0) > 0) return "edit";
396 return workLogEntryIsToolLike(entry) ? "other" : "update";
397}
398
399export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null {
400 const viewedImagePath = entry.viewedImagePath?.trim();
401 if (
402 viewedImagePath !== undefined &&
403 !/[\r\n]/.test(viewedImagePath) &&
404 isWorkspaceImagePreviewPath(viewedImagePath)
405 ) {
406 return viewedImagePath;
407 }
408 const detail = entry.detail?.trim();
409 return toolGroupAction(entry) === "read" &&
410 detail !== undefined &&
411 !/[\r\n]/.test(detail) &&
412 isWorkspaceImagePreviewPath(detail)
413 ? detail
414 : null;
415}
416
417/** Distinct images the agent opened across a tool group, in viewing order. */
418export function workEntriesViewedImagePaths(
419 entries: ReadonlyArray<WorkLogPresentationEntry>,
420): string[] {
421 return [...new Set(entries.flatMap((entry) => workEntryViewedImagePath(entry) ?? []))];
422}
423
424export interface ViewedImageAsset {
425 readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
426 readonly alt: string;
427 readonly srcFragment: string;
428}
429
430export function resolveViewedImageAsset(
431 source: string,
432 input: {
433 readonly threadId: ThreadId;
434 readonly workspaceRoot?: string | null | undefined;
435 },
436): ViewedImageAsset | null {
437 const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
438 if (imageSource._tag !== "WorkspaceFile") return null;
439 const resolvedFilePath =
440 input.workspaceRoot == null && imageSource.path.startsWith("./")
441 ? imageSource.path.slice(2)
442 : imageSource.path;
443
444 const media = resolveMediaSource(source, {
445 threadId: input.threadId,
446 workspaceRoot: input.workspaceRoot,
447 resolvedFilePath,
448 });
449 if (media === null || media.access !== "environment") return null;
450 return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment };
451}
452
453function toolGroupActionCount(
454 action: ToolGroupAction,
455 entries: ReadonlyArray<WorkLogPresentationEntry>,
456): number {
457 if (action !== "edit") return entries.length;
458 const changedFiles = new Set<string>();
459 let editsWithoutFileDetails = 0;
460 for (const entry of entries) {
461 if (!entry.changedFiles || entry.changedFiles.length === 0) {
462 editsWithoutFileDetails += 1;
463 } else {
464 for (const file of entry.changedFiles) changedFiles.add(file);
465 }
466 }
467 return changedFiles.size + editsWithoutFileDetails;
468}
469
470function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
471 switch (action) {
472 case "link-pr":
473 return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
474 case "unlink-pr":
475 return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
476 case "watch-pr":
477 return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
478 case "unwatch-pr":
479 return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
480 case "list-prs":
481 return count === 1
482 ? "Checked linked pull requests"
483 : `Checked linked pull requests ${count} times`;
484 case "read":
485 return `Read ${count} ${count === 1 ? "file" : "files"}`;
486 case "edit":
487 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
488 case "command":
489 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
490 case "thread-create":
491 return `Created ${count} ${count === 1 ? "thread" : "threads"}`;
492 case "device":
493 return `Used device controls ${count} ${count === 1 ? "time" : "times"}`;
494 case "browser":
495 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
496 case "search":
497 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
498 case "code-search":
499 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
500 case "other":
501 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
502 case "update":
503 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
504 }
505}
506
507function t3ToolSummaryCall(entry: WorkLogPresentationEntry): T3ToolSummaryCall {
508 return {
509 input: workEntryToolInput(entry),
510 output: workEntryToolOutput(entry),
511 outcome:
512 entry.toolLifecycleStatus === "failed" ||
513 entry.toolLifecycleStatus === "declined" ||
514 entry.tone === "error"
515 ? "failed"
516 : entry.toolLifecycleStatus === "completed"
517 ? "completed"
518 : "unfinished",
519 };
520}
521
522function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number {
523 switch (action) {
524 case "command":
525 case "edit":
526 case "delegate":
527 case "task-cancel":
528 case "thread-create":
529 case "thread-send":
530 case "thread-interrupt":
531 case "workflow-stop":
532 case "background-wait":
533 case "schedule-create":
534 case "schedule-update":
535 case "schedule-delete":
536 case "schedule-run":
537 case "thread-configure":
538 case "thread-fork":
539 case "thread-merge":
540 case "thread-organize":
541 case "thread-update":
542 case "queue-edit":
543 case "queue-cancel":
544 case "queue-reorder":
545 case "queue-steer":
546 case "question-respond":
547 case "worktree-handoff":
548 case "project-create":
549 case "project-update":
550 case "project-delete":
551 case "project-clone":
552 case "environment-update":
553 case "attachment-prepare":
554 case "attachment-discard":
555 case "attachment-send":
556 return 0;
557 case "other":
558 case "update":
559 return 2;
560 default:
561 return 1;
562 }
563}
564
565export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): {
566 summary: string;
567 hasFailure: boolean;
568} {
569 const toolEntries = entries.filter((entry) => entry.itemType !== "reasoning");
570 if (entries.length > 0 && toolEntries.length === 0) {
571 return {
572 summary: entries.length === 1 ? "Thought" : `Thought (×${entries.length})`,
573 hasFailure: false,
574 };
575 }
576 entries = toolEntries;
577 const groups = new Map<
578 ToolGroupAction | T3McpToolSummaryAction,
579 {
580 action: ToolGroupAction;
581 t3Action: T3McpToolSummaryAction | null;
582 entries: WorkLogPresentationEntry[];
583 }
584 >();
585 const sources = new Map<string, ToolActivitySource>();
586 for (const entry of entries) {
587 const t3Action = resolveT3McpSummaryAction(
588 workEntryToolName(entry),
589 t3ToolSummaryCall(entry).input,
590 );
591 if (entry.toolSource && t3Action === null) {
592 sources.set(entry.toolSource.key, entry.toolSource);
593 continue;
594 }
595 const action = toolGroupAction(entry);
596 const key = t3Action ?? action;
597 const group = groups.get(key);
598 if (group) group.entries.push(entry);
599 else groups.set(key, { action, t3Action, entries: [entry] });
600 }
601 const summaries = [...groups].map(([action, group], index) => ({
602 index,
603 count: group.entries.length,
604 priority: summaryActionPriority(action),
605 ...(group.t3Action
606 ? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall))
607 : {
608 label: toolGroupActionLabel(
609 group.action,
610 toolGroupActionCount(group.action, group.entries),
611 ),
612 failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length,
613 }),
614 }));
615 const selected = [...summaries]
616 .sort((a, b) => a.priority - b.priority || a.index - b.index)
617 .slice(0, 2)
618 .sort((a, b) => a.index - b.index);
619 const labels = selected.map(({ label }) => label);
620 if (sources.size > 0) {
621 const sourceValues = [...sources.values()];
622 const sourceNames = [...new Set(sourceValues.map((source) => source.name))];
623 const formattedNames =
624 sourceNames.length < 2
625 ? sourceNames[0]!
626 : sourceNames.length === 2
627 ? sourceNames.join(" and ")
628 : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`;
629 const allIntegrations = sourceValues.every((source) => source.kind === "integration");
630 labels.unshift(
631 `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`,
632 );
633 }
634 const sourcedCount = entries.filter(
635 (entry) =>
636 entry.toolSource !== undefined &&
637 resolveT3McpToolDefinition(workEntryToolName(entry)) === null,
638 ).length;
639 const remainingCount =
640 entries.length - sourcedCount - selected.reduce((count, group) => count + group.count, 0);
641 if (remainingCount > 0) {
642 labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`);
643 }
644 const sentenceLabels = labels.map((label, index) =>
645 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
646 );
647 const summary =
648 sentenceLabels.length < 3
649 ? sentenceLabels.join(" and ")
650 : `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
651 return { summary, hasFailure: summaries.some((group) => group.failedCount > 0) };
652}
653
654export function toolGroupSummaryKind(
655 entries: ReadonlyArray<WorkLogPresentationEntry>,
656): ToolGroupSummaryKind {
657 const toolEntries = entries.filter((entry) => entry.itemType !== "reasoning");
658 if (entries.length > 0 && toolEntries.length === 0) return "reasoning";
659 entries = toolEntries;
660 if (
661 entries.length > 0 &&
662 entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request")
663 )
664 return "pull-request";
665 const actions = new Set(entries.map(toolGroupAction));
666 if (actions.size !== 1) return "mixed";
667 const action = actions.values().next().value!;
668 if (action !== "other") return action;
669 const fallbackKinds = new Set(
670 entries.map((entry): ToolGroupSummaryKind => {
671 if (entry.itemType === "dynamic_tool") return "dynamic-tool";
672 if (entry.itemType === "subagent") return "agent-tool";
673 if (entry.tone === "thinking") return "agent-tool";
674 if (entry.tone === "tool") return "tone-tool";
675 return "other";
676 }),
677 );
678 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
679}
680
681export function liveThoughtLine(markdown: string): string {
682 const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1];
683 const text = (heading ?? markdown)
684 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
685 .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "")
686 .replace(/`+|\*\*|~~/g, "")
687 .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2")
688 .replace(/\s+/g, " ")
689 .trim();
690 if (heading !== undefined) return text;
691 const end = /[.?!]["'”’)]?(?=\s)/.exec(text);
692 return end ? text.slice(0, end.index + end[0].length) : text;
693}