1import {
2 isToolLifecycleItemType,
3 type AssetResource,
4 type RuntimeItemStatus,
5 type ToolActivitySource,
6 type ToolActivitySurface,
7 type ToolActivityIcon,
8 type OrchestrationV2TurnItem,
9 type ThreadId,
10} from "@t3tools/contracts";
11import {
12 resolveT3McpToolDefinition,
13 type T3McpToolDefinition,
14 type T3McpToolSummaryAction,
15} from "@t3tools/shared/t3McpToolPresentation";
16import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
17import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
18import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
19import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
20import { formatTokens } from "@t3tools/shared/usageFormat";
21import { classifyToolActivity } from "@t3tools/shared/toolActivity";
22import { toolOutputIndicatesFailure } from "@t3tools/shared/toolOutput";
23
24import {
25 summarizeT3ToolCalls,
26 t3ToolResultIndicatesFailure,
27 type T3ToolSummaryCall,
28} from "@t3tools/client-runtime/t3ToolSummary";
29
30export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped" | "idle";
31
32/** Inspection and copying omit raw outputs, including values from older caches. */
33export function toolItemForDisplay(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
34 switch (item.type) {
35 case "command_execution":
36 case "dynamic_tool": {
37 const { output: _output, ...displayItem } = item;
38 return displayItem;
39 }
40 case "file_change": {
41 const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;
42 // A failed edit's diffStr holds the provider's error, not a diff.
43 return item.status === "failed" && diffStr?.trim()
44 ? { ...displayItem, diffStr }
45 : displayItem;
46 }
47 default:
48 return item;
49 }
50}
51
52export function contextCompactionLabel(
53 item: Pick<
54 Extract<OrchestrationV2TurnItem, { type: "compaction" }>,
55 "status" | "beforeTokenCount" | "afterTokenCount"
56 >,
57): string {
58 if (item.status === "pending" || item.status === "running" || item.status === "waiting") {
59 return "Compacting context";
60 }
61 if (item.status === "failed") return "Context compaction failed";
62 if (item.status === "cancelled" || item.status === "interrupted") {
63 return "Context compaction stopped";
64 }
65 if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) {
66 return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`;
67 }
68 return "Context compacted";
69}
70
71export interface WorkLogPresentationEntry {
72 readonly questionAnswer?: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
73 readonly id: string;
74 readonly createdAt: string;
75 readonly label: string;
76 readonly tone: "thinking" | "tool" | "info" | "error";
77 readonly command?: string;
78 readonly rawCommand?: string;
79 readonly detail?: string;
80 readonly viewedImagePath?: string;
81 readonly changedFiles?: ReadonlyArray<string>;
82 readonly toolTitle?: string;
83 readonly toolData?: unknown;
84 readonly requestKind?: string;
85 readonly itemType?: OrchestrationV2TurnItem["type"];
86 readonly toolLifecycleStatus?: WorkLogToolLifecycleStatus;
87 readonly structuredPayload?: OrchestrationV2TurnItem;
88 readonly toolSource?: ToolActivitySource;
89 readonly toolSurface?: ToolActivitySurface;
90 readonly toolIcon?: ToolActivityIcon;
91}
92
93export type ToolGroupAction =
94 | "link-pr"
95 | "unlink-pr"
96 | "list-prs"
97 | "watch-pr"
98 | "unwatch-pr"
99 | "read"
100 | "edit"
101 | "command"
102 | "thread-create"
103 | "browser"
104 | "device"
105 | "code-search"
106 | "search"
107 | "other"
108 | "update";
109
110export type ToolGroupSummaryKind =
111 | "pull-request"
112 | ToolGroupAction
113 | "dynamic-tool"
114 | "reasoning"
115 | "agent-tool"
116 | "tone-tool"
117 | "mixed";
118
119export function normalizeCompactToolLabel(value: string): string {
120 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
121}
122
123/** Structured identity is authoritative, including when it identifies a foreign server. */
124function workEntryToolName(
125 entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "structuredPayload">,
126): string | undefined {
127 const item = entry.structuredPayload;
128 if (item?.type === "dynamic_tool" && item.toolName) return item.toolName;
129 const data = asRecord(entry.toolData);
130 if (typeof data?.server === "string" && typeof data.tool === "string") {
131 return `${data.server}.${data.tool}`;
132 }
133 if (typeof data?.toolName === "string") return data.toolName;
134 return resolveT3McpToolDefinition(entry.toolTitle) ? entry.toolTitle : entry.label;
135}
136
137function workEntryToolOutput(
138 entry: Pick<WorkLogPresentationEntry, "toolData" | "structuredPayload">,
139): unknown {
140 const item = entry.structuredPayload;
141 const data = asRecord(entry.toolData);
142 return item?.type === "dynamic_tool"
143 ? item.output
144 : (data?.output ?? data?.result ?? data?.rawOutput ?? data?.content);
145}
146
147function resolveT3McpToolPresentation(
148 definition: T3McpToolDefinition | null,
149 status: string | undefined,
150 data?: unknown,
151) {
152 if (!definition) return null;
153 const [action, running, completed, detail] = definition.labels;
154 const verb =
155 status === "inProgress"
156 ? running
157 : status === "completed"
158 ? completed
159 : status === "failed"
160 ? `Failed to ${action.toLowerCase()}`
161 : status === "declined"
162 ? `Declined to ${action.toLowerCase()}`
163 : status === "stopped"
164 ? `Stopped ${running.toLowerCase()}`
165 : running;
166
167 const actionKind =
168 definition.summaryAction === "link-pr" ||
169 definition.summaryAction === "unlink-pr" ||
170 definition.summaryAction === "list-prs" ||
171 definition.summaryAction === "watch-pr" ||
172 definition.summaryAction === "unwatch-pr"
173 ? definition.summaryAction
174 : undefined;
175 const payload = asRecord(data);
176 const input =
177 asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput);
178 const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null;
179 const number = urlTarget?.number ?? input?.number;
180 const target =
181 actionKind !== undefined &&
182 actionKind !== "list-prs" &&
183 typeof number === "number" &&
184 Number.isSafeInteger(number) &&
185 number > 0
186 ? `PR #${number}`
187 : detail;
188 return {
189 displayName: `${verb} ${target}`,
190 icon: definition.icon,
191 ...(actionKind === undefined ? {} : { action: actionKind }),
192 };
193}
194
195/** Only active or completed calls can inherit the live group’s present tense. */
196export function liveActivityToolStatus(status: string | undefined, presentTense: boolean) {
197 if (status === "failed" || status === "declined" || status === "stopped" || status === "idle") {
198 return status;
199 }
200 if (presentTense || status === "inProgress") return "inProgress";
201 return "completed";
202}
203
204/** Resolves tool identity before choosing labels or icons in either client. */
205export function resolveWorkEntryToolPresentation(
206 entry: Pick<
207 WorkLogPresentationEntry,
208 "label" | "toolTitle" | "toolData" | "toolLifecycleStatus" | "structuredPayload"
209 >,
210 fallbackStatus?: "inProgress" | "completed",
211) {
212 const definition = resolveT3McpToolDefinition(workEntryToolName(entry));
213 const status = entry.toolLifecycleStatus ?? fallbackStatus;
214 return resolveT3McpToolPresentation(
215 definition,
216 definition && t3ToolResultIndicatesFailure(workEntryToolOutput(entry)) ? "failed" : status,
217 entry.toolData,
218 );
219}
220
221function asRecord(value: unknown): Record<string, unknown> | null {
222 return value !== null && typeof value === "object" && !Array.isArray(value)
223 ? (value as Record<string, unknown>)
224 : null;
225}
226
227function nonEmptyString(value: unknown): string | null {
228 return typeof value === "string" && value.trim().length > 0 ? value : null;
229}
230
231function commandResultContent(value: unknown): string | null {
232 const direct = nonEmptyString(value);
233 if (direct) return direct;
234
235 const directContent = Array.isArray(value) ? value : null;
236 const record = asRecord(value);
237 const content = record?.content;
238 const contentText = nonEmptyString(content);
239 if (contentText) return contentText;
240 const blocks = directContent ?? (Array.isArray(content) ? content : null);
241 if (!blocks) return null;
242
243 const chunks = blocks.flatMap((entry) => {
244 const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text);
245 return text ? [text] : [];
246 });
247 return chunks.length > 0 ? chunks.join("\n") : null;
248}
249
250/** Returns provider command output before it is formatted for a work-log row. */
251export function extractCommandOutputText(dataValue: unknown): string | null {
252 const data = asRecord(dataValue);
253 const item = asRecord(data?.item);
254 const itemResult = asRecord(item?.result);
255 const rawOutput = asRecord(data?.rawOutput);
256 const outputStreams = [
257 nonEmptyString(rawOutput?.stdout),
258 nonEmptyString(rawOutput?.stderr),
259 ].filter((value): value is string => value !== null);
260 const acpContent = Array.isArray(data?.content)
261 ? data.content
262 .flatMap((entryValue) => {
263 const entry = asRecord(entryValue);
264 const content = asRecord(entry?.content);
265 const text = entry?.type === "content" ? nonEmptyString(content?.text) : null;
266 return text ? [text] : [];
267 })
268 .join("\n")
269 : null;
270
271 const candidates = [
272 item?.aggregatedOutput,
273 itemResult?.content,
274 data?.rawOutput,
275 rawOutput?.content,
276 outputStreams.length > 0 ? outputStreams.join("\n") : null,
277 rawOutput?.output,
278 acpContent,
279 data?.result,
280 ];
281 for (const candidate of candidates) {
282 const text = commandResultContent(candidate);
283 if (text) return text;
284 }
285 return null;
286}
287
288/**
289 * Ingestion caps tool details at 180 chars and appends "...", so a long command
290 * echo no longer equals the command it repeats. Treat a truncated prefix of the
291 * command as the same echo.
292 */
293function textRepeatsCommand(text: string, commands: ReadonlyArray<string | null>): boolean {
294 const truncated = text.endsWith("...")
295 ? text.slice(0, -3)
296 : text.endsWith("\u2026")
297 ? text.slice(0, -1)
298 : null;
299 return commands.some((candidate) => {
300 const command = candidate?.trim();
301 if (!command) return false;
302 if (command === text) return true;
303 return (
304 truncated !== null &&
305 truncated.length > 0 &&
306 command.length > truncated.length &&
307 command.startsWith(truncated)
308 );
309 });
310}
311
312/**
313 * Decides whether a command row's `detail` is a synthetic echo of the command
314 * rather than real output. OpenCode stores completed output in `detail` with no
315 * other output channel, so plain equality is only treated as synthetic when the
316 * payload shape shows the detail came from the command: Codex item metadata,
317 * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name
318 * prefix, or no structured command at all.
319 */
320export function commandDetailRepeatsCommand(input: {
321 readonly detail: string;
322 readonly command: string | null;
323 readonly rawCommand: string | null;
324 readonly toolName: unknown;
325 readonly data: unknown;
326}): boolean {
327 const toolName = nonEmptyString(input.toolName)?.trim();
328 const detail = input.detail.trim();
329 const commands = [input.command, input.rawCommand];
330 if (toolName) {
331 const prefix = `${toolName}:`;
332 if (detail.toLowerCase().startsWith(prefix.toLowerCase())) {
333 const unprefixed = detail.slice(prefix.length).trim();
334 if (textRepeatsCommand(unprefixed, commands)) return true;
335 }
336 }
337
338 if (!textRepeatsCommand(detail, commands)) return false;
339
340 const data = asRecord(input.data);
341 const item = asRecord(data?.item);
342 const itemInput = asRecord(item?.input);
343 const itemResult = asRecord(item?.result);
344 const hasStructuredCommand = [
345 item?.command,
346 itemInput?.command,
347 itemResult?.command,
348 data?.command,
349 ].some((value) =>
350 Array.isArray(value)
351 ? value.some((part) => nonEmptyString(part) !== null)
352 : nonEmptyString(value) !== null,
353 );
354 return (
355 !hasStructuredCommand ||
356 item !== null ||
357 data?.toolCallId !== undefined ||
358 nonEmptyString(data?.kind)?.toLowerCase() === "execute"
359 );
360}
361
362function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
363 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
364 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
365 if (entry.requestKind !== undefined) return true;
366 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
367}
368
369function workEntryIndicatesToolFailureFromOutput(
370 entry: WorkLogPresentationEntry,
371 includeCommand: boolean,
372): boolean {
373 if (
374 entry.tone === "error" ||
375 entry.toolLifecycleStatus === "failed" ||
376 entry.toolLifecycleStatus === "declined"
377 ) {
378 return true;
379 }
380 if (!workLogEntryIsToolLike(entry)) return false;
381 if (
382 resolveT3McpToolDefinition(workEntryToolName(entry)) &&
383 t3ToolResultIndicatesFailure(workEntryToolOutput(entry))
384 ) {
385 return true;
386 }
387 const item = entry.structuredPayload;
388 if (item?.type === "command_execution") {
389 if (item.outputIndicatesFailure || (item.exitCode !== undefined && item.exitCode !== 0)) {
390 return true;
391 }
392 // Older servers/caches can still carry output. Read only the previous
393 // preview-sized prefix for status, without exposing it in the row detail.
394 if (item.output && toolOutputIndicatesFailure(item.output.slice(0, 32_768))) {
395 return true;
396 }
397 }
398 const output = includeCommand
399 ? [entry.detail, entry.command].filter(Boolean).join("\n")
400 : (entry.detail ?? "");
401 return output.length > 0 && toolOutputIndicatesFailure(output);
402}
403
404/** Includes legacy activities that stored error output in the command field. */
405export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean {
406 return workEntryIndicatesToolFailureFromOutput(entry, true);
407}
408
409/** Checks rendered output without treating the user's command as an error. */
410export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean {
411 return workEntryIndicatesToolFailureFromOutput(entry, false);
412}
413
414/** Decides whether the row can show a success marker. */
415export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry): boolean {
416 return (
417 workLogEntryIsToolLike(entry) &&
418 !workEntryIndicatesToolFailure(entry) &&
419 entry.tone !== "thinking" &&
420 entry.toolLifecycleStatus !== "idle" &&
421 entry.toolLifecycleStatus !== "inProgress" &&
422 entry.toolLifecycleStatus !== "stopped"
423 );
424}
425
426function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
427 return (
428 entry.itemType === "file_search" ||
429 (entry.itemType === "web_search" &&
430 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)))
431 );
432}
433
434export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
435 if (entry.itemType === "thread_created") return "thread-create";
436 const presentation = resolveWorkEntryToolPresentation(entry);
437 if (presentation?.action !== undefined) return presentation.action;
438 if (presentation?.icon === "browser") return "browser";
439 if (presentation?.icon === "device") return "device";
440 if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read";
441 // Approvals and questions describe requested work, not work that ran.
442 if (entry.itemType === "approval_request" || entry.itemType === "user_input_request") {
443 return workLogEntryIsToolLike(entry) ? "other" : "update";
444 }
445 const data = asRecord(entry.toolData) ?? {};
446 const toolName =
447 entry.structuredPayload?.type === "dynamic_tool"
448 ? entry.structuredPayload.toolName
449 : typeof data.toolName === "string"
450 ? data.toolName
451 : entry.toolTitle;
452 const classified = classifyToolActivity({
453 itemType:
454 entry.itemType === "command_execution" ||
455 entry.itemType === "file_change" ||
456 entry.itemType === "web_search"
457 ? entry.itemType
458 : entry.itemType === "dynamic_tool"
459 ? "dynamic_tool_call"
460 : undefined,
461 title: entry.toolTitle ?? entry.label,
462 data: {
463 ...data,
464 ...(toolName ? { toolName } : {}),
465 },
466 });
467 if (classified === "read") return "read";
468 if (classified === "file_change" || entry.itemType === "file_change") return "edit";
469 if (classified === "command" || entry.itemType === "command_execution" || entry.command) {
470 return "command";
471 }
472 if (classified === "search") {
473 return entry.itemType === "web_search" && !workLogEntryIsLocalCodeSearch(entry)
474 ? "search"
475 : "code-search";
476 }
477 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
478 if (entry.itemType === "web_search") return "search";
479 if ((entry.changedFiles?.length ?? 0) > 0) return "edit";
480 return workLogEntryIsToolLike(entry) ? "other" : "update";
481}
482
483export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null {
484 const viewedImagePath = entry.viewedImagePath?.trim();
485 if (
486 viewedImagePath !== undefined &&
487 !/[\r\n]/.test(viewedImagePath) &&
488 isWorkspaceImagePreviewPath(viewedImagePath)
489 ) {
490 return viewedImagePath;
491 }
492 const detail = entry.detail?.trim();
493 return toolGroupAction(entry) === "read" &&
494 detail !== undefined &&
495 !/[\r\n]/.test(detail) &&
496 isWorkspaceImagePreviewPath(detail)
497 ? detail
498 : null;
499}
500
501export interface ViewedImageAsset {
502 readonly resource: Extract<AssetResource, { readonly _tag: "media-file" }>;
503 readonly alt: string;
504 readonly srcFragment: string;
505}
506
507export function resolveViewedImageAsset(
508 source: string,
509 input: {
510 readonly threadId: ThreadId;
511 readonly workspaceRoot?: string | null | undefined;
512 },
513): ViewedImageAsset | null {
514 // A relative path with no known workspace still names a media-file relative
515 // to the thread's workspace, so classify against "." and drop the prefix.
516 const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
517 if (imageSource._tag !== "WorkspaceFile") return null;
518 const resolvedFilePath =
519 input.workspaceRoot == null && imageSource.path.startsWith("./")
520 ? imageSource.path.slice(2)
521 : imageSource.path;
522
523 const media = resolveMediaSource(source, {
524 threadId: input.threadId,
525 workspaceRoot: input.workspaceRoot,
526 resolvedFilePath,
527 });
528 if (media === null || media.access !== "environment") return null;
529 return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment };
530}
531
532function toolGroupActionCount(
533 action: ToolGroupAction,
534 entries: ReadonlyArray<WorkLogPresentationEntry>,
535): number {
536 if (action !== "edit") return entries.length;
537 const changedFiles = new Set<string>();
538 let editsWithoutFileDetails = 0;
539 for (const entry of entries) {
540 if (!entry.changedFiles || entry.changedFiles.length === 0) {
541 editsWithoutFileDetails += 1;
542 } else {
543 for (const file of entry.changedFiles) changedFiles.add(file);
544 }
545 }
546 return changedFiles.size + editsWithoutFileDetails;
547}
548
549function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
550 switch (action) {
551 case "link-pr":
552 return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
553 case "unlink-pr":
554 return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
555 case "watch-pr":
556 return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
557 case "unwatch-pr":
558 return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`;
559 case "list-prs":
560 return count === 1
561 ? "Checked linked pull requests"
562 : `Checked linked pull requests ${count} times`;
563 case "read":
564 return `Read ${count} ${count === 1 ? "file" : "files"}`;
565 case "edit":
566 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
567 case "command":
568 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
569 case "thread-create":
570 return `Created ${count} ${count === 1 ? "thread" : "threads"}`;
571 case "device":
572 return `Used device controls ${count} ${count === 1 ? "time" : "times"}`;
573 case "browser":
574 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
575 case "search":
576 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
577 case "code-search":
578 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
579 case "other":
580 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
581 case "update":
582 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
583 }
584}
585
586function t3ToolSummaryCall(entry: WorkLogPresentationEntry): T3ToolSummaryCall {
587 const item = entry.structuredPayload;
588 const data =
589 entry.toolData !== null && typeof entry.toolData === "object"
590 ? (entry.toolData as Record<string, unknown>)
591 : undefined;
592 return {
593 input:
594 item?.type === "dynamic_tool"
595 ? item.input
596 : (data?.arguments ?? data?.input ?? data?.rawInput),
597 output: workEntryToolOutput(entry),
598 outcome:
599 entry.toolLifecycleStatus === "failed" ||
600 entry.toolLifecycleStatus === "declined" ||
601 entry.tone === "error"
602 ? "failed"
603 : entry.toolLifecycleStatus === "completed"
604 ? "completed"
605 : "unfinished",
606 };
607}
608
609function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number {
610 switch (action) {
611 case "command":
612 case "edit":
613 case "delegate":
614 case "task-cancel":
615 case "thread-create":
616 case "thread-send":
617 case "thread-interrupt":
618 case "workflow-stop":
619 case "schedule-create":
620 case "schedule-update":
621 case "schedule-delete":
622 case "schedule-run":
623 case "thread-configure":
624 case "thread-fork":
625 case "thread-merge":
626 case "thread-organize":
627 case "thread-update":
628 case "queue-edit":
629 case "queue-cancel":
630 case "queue-reorder":
631 case "queue-steer":
632 case "question-respond":
633 case "worktree-handoff":
634 case "project-create":
635 case "project-update":
636 case "project-delete":
637 case "project-clone":
638 case "environment-update":
639 case "attachment-prepare":
640 case "attachment-discard":
641 case "attachment-send":
642 return 0;
643 case "other":
644 case "update":
645 return 2;
646 default:
647 return 1;
648 }
649}
650
651/** Summarizes at most two action categories; every omitted call still counts in the remainder. */
652export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEntry>): {
653 summary: string;
654 hasFailure: boolean;
655} {
656 const toolEntries = entries.filter((entry) => entry.itemType !== "reasoning");
657 if (entries.length > 0 && toolEntries.length === 0) {
658 return {
659 summary: entries.length === 1 ? "Thought" : `Thought (×${entries.length})`,
660 hasFailure: false,
661 };
662 }
663 entries = toolEntries;
664 const groups = new Map<
665 ToolGroupAction | T3McpToolSummaryAction,
666 {
667 action: ToolGroupAction;
668 t3Action: T3McpToolSummaryAction | null;
669 entries: WorkLogPresentationEntry[];
670 }
671 >();
672 const sources = new Map<string, ToolActivitySource>();
673 for (const entry of entries) {
674 const t3Action = resolveT3McpToolDefinition(workEntryToolName(entry))?.summaryAction ?? null;
675 if (entry.toolSource && t3Action === null) {
676 sources.set(entry.toolSource.key, entry.toolSource);
677 continue;
678 }
679 const action = toolGroupAction(entry);
680 const key = t3Action ?? action;
681 const group = groups.get(key);
682 if (group) group.entries.push(entry);
683 else groups.set(key, { action, t3Action, entries: [entry] });
684 }
685 const summaries = [...groups].map(([action, group], index) => ({
686 index,
687 count: group.entries.length,
688 priority: summaryActionPriority(action),
689 ...(group.t3Action
690 ? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall))
691 : {
692 label: toolGroupActionLabel(
693 group.action,
694 toolGroupActionCount(group.action, group.entries),
695 ),
696 failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length,
697 }),
698 }));
699 const selected = [...summaries]
700 .sort((a, b) => a.priority - b.priority || a.index - b.index)
701 .slice(0, 2)
702 .sort((a, b) => a.index - b.index);
703 const labels = selected.map(({ label }) => label);
704 if (sources.size > 0) {
705 const sourceValues = [...sources.values()];
706 const sourceNames = [...new Set(sourceValues.map((source) => source.name))];
707 const formattedNames =
708 sourceNames.length < 2
709 ? sourceNames[0]!
710 : sourceNames.length === 2
711 ? sourceNames.join(" and ")
712 : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`;
713 const allIntegrations = sourceValues.every((source) => source.kind === "integration");
714 labels.unshift(
715 `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`,
716 );
717 }
718 const sourcedCount = entries.filter(
719 (entry) =>
720 entry.toolSource !== undefined &&
721 resolveT3McpToolDefinition(workEntryToolName(entry)) === null,
722 ).length;
723 const remainingCount =
724 entries.length - sourcedCount - selected.reduce((count, group) => count + group.count, 0);
725 if (remainingCount > 0) {
726 labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`);
727 }
728 const sentenceLabels = labels.map((label, index) =>
729 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
730 );
731 const summary =
732 sentenceLabels.length < 3
733 ? sentenceLabels.join(" and ")
734 : `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
735 return { summary, hasFailure: summaries.some((group) => group.failedCount > 0) };
736}
737
738export function toolGroupSummaryKind(
739 entries: ReadonlyArray<WorkLogPresentationEntry>,
740): ToolGroupSummaryKind {
741 const toolEntries = entries.filter((entry) => entry.itemType !== "reasoning");
742 if (entries.length > 0 && toolEntries.length === 0) return "reasoning";
743 entries = toolEntries;
744 if (
745 entries.length > 0 &&
746 entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request")
747 )
748 return "pull-request";
749 const actions = new Set(entries.map(toolGroupAction));
750 if (actions.size !== 1) return "mixed";
751 const action = actions.values().next().value!;
752 if (action !== "other") return action;
753 const fallbackKinds = new Set(
754 entries.map((entry): ToolGroupSummaryKind => {
755 if (entry.itemType === "dynamic_tool") return "dynamic-tool";
756 if (entry.itemType === "subagent") return "agent-tool";
757 if (entry.tone === "thinking") return "agent-tool";
758 if (entry.tone === "tool") return "tone-tool";
759 return "other";
760 }),
761 );
762 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
763}
764
765/**
766 * Plain-text line for the latest thought in the live activity row. A
767 * bold-only opening line (the Codex summary heading) wins; otherwise this is
768 * the first sentence of the reasoning text. Web and mobile both render it.
769 */
770export function liveThoughtLine(markdown: string): string {
771 const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1];
772 const text = (heading ?? markdown)
773 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
774 .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "")
775 .replace(/`+|\*\*|~~/g, "")
776 .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2")
777 .replace(/\s+/g, " ")
778 .trim();
779 if (heading !== undefined) return text;
780 // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows.
781 const end = /[.?!]["'”’)]?(?=\s)/.exec(text);
782 return end ? text.slice(0, end.index + end[0].length) : text;
783}