authorgravatar for zortosdev@proton.meZortos <zortosdev@proton.me> 2026-03-13 03:42:59+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 19:42:59-07:00
log64dc07a8e5a6d7e34de4e85a07126a9c04e462d3
treee01144e0650567763005fff3886abcdc66ab2a08
parentae4ae9973cd6cebc7d557e64e876534f15c916fd
signature Signed by PGP key B5690EEEBB952194

Add compact Codex tool-call icons and details to the chat timeline (#988)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

10 files changed, 321 insertions(+), 96 deletions(-)

apps/server/integration/fixtures/providerRuntime.ts+2-2
...@@ -73,7 +73,7 @@ export const codexTurnToolFixture = [...@@ -73,7 +73,7 @@ export const codexTurnToolFixture = [
73 turnId: TURN_ID,73 turnId: TURN_ID,
74 payload: {74 payload: {
75 itemType: "command_execution",75 itemType: "command_execution",
76 title: "Command run",76 title: "Ran command",
77 detail: "echo integration",77 detail: "echo integration",
78 },78 },
79 },79 },
...@@ -85,7 +85,7 @@ export const codexTurnToolFixture = [...@@ -85,7 +85,7 @@ export const codexTurnToolFixture = [
85 payload: {85 payload: {
86 itemType: "command_execution",86 itemType: "command_execution",
87 status: "completed",87 status: "completed",
88 title: "Command run",88 title: "Ran command",
89 detail: "echo integration",89 detail: "echo integration",
90 },90 },
91 },91 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+2-13
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 MessageId,5 MessageId,
6 type OrchestrationEvent,6 type OrchestrationEvent,
7 CheckpointRef,7 CheckpointRef,
8 isToolLifecycleItemType,
8 ThreadId,9 ThreadId,
9 TurnId,10 TurnId,
10 type OrchestrationThreadActivity,11 type OrchestrationThreadActivity,
...@@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType(...@@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType(
173 }174 }
174}175}
175176
176function isToolLifecycleItemType(itemType: string): boolean {
177 return (
178 itemType === "command_execution" ||
179 itemType === "file_change" ||
180 itemType === "mcp_tool_call" ||
181 itemType === "dynamic_tool_call" ||
182 itemType === "collab_agent_tool_call" ||
183 itemType === "web_search" ||
184 itemType === "image_view"
185 );
186}
187
188function runtimeEventToActivities(177function runtimeEventToActivities(
189 event: ProviderRuntimeEvent,178 event: ProviderRuntimeEvent,
190): ReadonlyArray<OrchestrationThreadActivity> {179): ReadonlyArray<OrchestrationThreadActivity> {
...@@ -449,7 +438,7 @@ function runtimeEventToActivities(...@@ -449,7 +438,7 @@ function runtimeEventToActivities(
449 createdAt: event.createdAt,438 createdAt: event.createdAt,
450 tone: "tool",439 tone: "tool",
451 kind: "tool.completed",440 kind: "tool.completed",
452 summary: `${event.payload.title ?? "Tool"} complete`,441 summary: event.payload.title ?? "Tool",
453 payload: {442 payload: {
454 itemType: event.payload.itemType,443 itemType: event.payload.itemType,
455 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),444 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
apps/server/src/provider/Layers/CodexAdapter.ts+1-1
...@@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined {...@@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined {
164 case "plan":164 case "plan":
165 return "Plan";165 return "Plan";
166 case "command_execution":166 case "command_execution":
167 return "Command run";167 return "Ran command";
168 case "file_change":168 case "file_change":
169 return "File change";169 return "File change";
170 case "mcp_tool_call":170 case "mcp_tool_call":
apps/server/src/provider/Layers/ProviderService.test.ts+3-3
...@@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
703 threadId: session.threadId,703 threadId: session.threadId,
704 turnId: asTurnId("turn-1"),704 turnId: asTurnId("turn-1"),
705 toolKind: "command",705 toolKind: "command",
706 title: "Command run",706 title: "Ran command",
707 });707 });
708 fanout.codex.emit({708 fanout.codex.emit({
709 type: "tool.completed",709 type: "tool.completed",
...@@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
713 threadId: session.threadId,713 threadId: session.threadId,
714 turnId: asTurnId("turn-1"),714 turnId: asTurnId("turn-1"),
715 toolKind: "command",715 toolKind: "command",
716 title: "Command run",716 title: "Ran command",
717 });717 });
718 fanout.codex.emit({718 fanout.codex.emit({
719 type: "turn.completed",719 type: "turn.completed",
...@@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
768 threadId: session.threadId,768 threadId: session.threadId,
769 turnId: asTurnId("turn-1"),769 turnId: asTurnId("turn-1"),
770 toolKind: "command",770 toolKind: "command",
771 title: "Command run",771 title: "Ran command",
772 detail: "echo one",772 detail: "echo one",
773 },773 },
774 {774 {
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+11-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { computeMessageDurationStart } from "./MessagesTimeline.logic";2import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
33
4describe("computeMessageDurationStart", () => {4describe("computeMessageDurationStart", () => {
5 it("returns message createdAt when there is no preceding user message", () => {5 it("returns message createdAt when there is no preceding user message", () => {
...@@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => {...@@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => {
133 expect(computeMessageDurationStart([])).toEqual(new Map());133 expect(computeMessageDurationStart([])).toEqual(new Map());
134 });134 });
135});135});
136
137describe("normalizeCompactToolLabel", () => {
138 it("removes trailing completion wording from command labels", () => {
139 expect(normalizeCompactToolLabel("Ran command complete")).toBe("Ran command");
140 });
141
142 it("removes trailing completion wording from other labels", () => {
143 expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144 });
145});
apps/web/src/components/chat/MessagesTimeline.logic.ts+4
...@@ -23,3 +23,7 @@ export function computeMessageDurationStart(...@@ -23,3 +23,7 @@ export function computeMessageDurationStart(
2323
24 return result;24 return result;
25}25}
26
27export function normalizeCompactToolLabel(value: string): string {
28 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
29}
apps/web/src/components/chat/MessagesTimeline.tsx+176-65
...@@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";...@@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
10import { type TurnDiffSummary } from "../../types";10import { type TurnDiffSummary } from "../../types";
11import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";11import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
12import ChatMarkdown from "../ChatMarkdown";12import ChatMarkdown from "../ChatMarkdown";
13import { Undo2Icon } from "lucide-react";13import {
14 BotIcon,
15 CheckIcon,
16 CircleAlertIcon,
17 EyeIcon,
18 GlobeIcon,
19 HammerIcon,
20 type LucideIcon,
21 SquarePenIcon,
22 TerminalIcon,
23 Undo2Icon,
24 WrenchIcon,
25 ZapIcon,
26} from "lucide-react";
14import { Button } from "../ui/button";27import { Button } from "../ui/button";
15import { clamp } from "effect/Number";28import { clamp } from "effect/Number";
16import { estimateTimelineMessageHeight } from "../timelineHeight";29import { estimateTimelineMessageHeight } from "../timelineHeight";
...@@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";...@@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
19import { ChangedFilesTree } from "./ChangedFilesTree";32import { ChangedFilesTree } from "./ChangedFilesTree";
20import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";33import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
21import { MessageCopyButton } from "./MessageCopyButton";34import { MessageCopyButton } from "./MessageCopyButton";
22import { computeMessageDurationStart } from "./MessagesTimeline.logic";35import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
36import { cn } from "~/lib/utils";
23import { type TimestampFormat } from "../../appSettings";37import { type TimestampFormat } from "../../appSettings";
24import { formatTimestamp } from "../../timestampFormat";38import { formatTimestamp } from "../../timestampFormat";
2539
...@@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
289 : groupedEntries;303 : groupedEntries;
290 const hiddenCount = groupedEntries.length - visibleEntries.length;304 const hiddenCount = groupedEntries.length - visibleEntries.length;
291 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");305 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
292 const groupLabel = onlyToolEntries306 const showHeader = hasOverflow || !onlyToolEntries;
293 ? groupedEntries.length === 1307 const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
294 ? "Tool call"
295 : `Tool calls (${groupedEntries.length})`
296 : groupedEntries.length === 1
297 ? "Work event"
298 : `Work log (${groupedEntries.length})`;
299308
300 return (309 return (
301 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">310 <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">
302 <div className="mb-1.5 flex items-center justify-between gap-3">311 {showHeader && (
303 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">312 <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
304 {groupLabel}313 <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">
305 </p>314 {groupLabel} ({groupedEntries.length})
306 {hasOverflow && (315 </p>
307 <button316 {hasOverflow && (
308 type="button"317 <button
309 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"318 type="button"
310 onClick={() => onToggleWorkGroup(groupId)}319 className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
311 >320 onClick={() => onToggleWorkGroup(groupId)}
312 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}321 >
313 </button>322 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
314 )}323 </button>
315 </div>324 )}
316 <div className="space-y-1">325 </div>
326 )}
327 <div className="space-y-0.5">
317 {visibleEntries.map((workEntry) => (328 {visibleEntries.map((workEntry) => (
318 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">329 <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} />
319 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
320 <div className="min-w-0 flex-1 py-[2px]">
321 <p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
322 {workEntry.label}
323 </p>
324 {workEntry.command && (
325 <pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80">
326 {workEntry.command}
327 </pre>
328 )}
329 {workEntry.changedFiles && workEntry.changedFiles.length > 0 && (
330 <div className="mt-1 flex flex-wrap gap-1">
331 {workEntry.changedFiles.slice(0, 6).map((filePath) => (
332 <span
333 key={`${workEntry.id}:${filePath}`}
334 className="rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85"
335 title={filePath}
336 >
337 {filePath}
338 </span>
339 ))}
340 {workEntry.changedFiles.length > 6 && (
341 <span className="px-1 text-[10px] text-muted-foreground/65">
342 +{workEntry.changedFiles.length - 6} more
343 </span>
344 )}
345 </div>
346 )}
347 {workEntry.detail &&
348 (!workEntry.command || workEntry.detail !== workEntry.command) && (
349 <p
350 className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75"
351 title={workEntry.detail}
352 >
353 {workEntry.detail}
354 </p>
355 )}
356 </div>
357 </div>
358 ))}330 ))}
359 </div>331 </div>
360 </div>332 </div>
...@@ -664,9 +636,148 @@ function formatMessageMeta(...@@ -664,9 +636,148 @@ function formatMessageMeta(
664 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;636 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
665}637}
666638
639function workToneIcon(tone: TimelineWorkEntry["tone"]): {
640 icon: LucideIcon;
641 className: string;
642} {
643 if (tone === "error") {
644 return {
645 icon: CircleAlertIcon,
646 className: "text-foreground/92",
647 };
648 }
649 if (tone === "thinking") {
650 return {
651 icon: BotIcon,
652 className: "text-foreground/92",
653 };
654 }
655 if (tone === "info") {
656 return {
657 icon: CheckIcon,
658 className: "text-foreground/92",
659 };
660 }
661 return {
662 icon: ZapIcon,
663 className: "text-foreground/92",
664 };
665}
666
667function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {667function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
668 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";668 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
669 if (tone === "tool") return "text-muted-foreground/70";669 if (tone === "tool") return "text-muted-foreground/70";
670 if (tone === "thinking") return "text-muted-foreground/50";670 if (tone === "thinking") return "text-muted-foreground/50";
671 return "text-muted-foreground/40";671 return "text-muted-foreground/40";
672}672}
673
674function workEntryPreview(
675 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
676) {
677 if (workEntry.command) return workEntry.command;
678 if (workEntry.detail) return workEntry.detail;
679 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
680 const [firstPath] = workEntry.changedFiles ?? [];
681 if (!firstPath) return null;
682 return workEntry.changedFiles!.length === 1
683 ? firstPath
684 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
685}
686
687function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
688 if (workEntry.requestKind === "command") return TerminalIcon;
689 if (workEntry.requestKind === "file-read") return EyeIcon;
690 if (workEntry.requestKind === "file-change") return SquarePenIcon;
691
692 if (workEntry.itemType === "command_execution" || workEntry.command) {
693 return TerminalIcon;
694 }
695 if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) {
696 return SquarePenIcon;
697 }
698 if (workEntry.itemType === "web_search") return GlobeIcon;
699 if (workEntry.itemType === "image_view") return EyeIcon;
700
701 switch (workEntry.itemType) {
702 case "mcp_tool_call":
703 return WrenchIcon;
704 case "dynamic_tool_call":
705 case "collab_agent_tool_call":
706 return HammerIcon;
707 }
708
709 return workToneIcon(workEntry.tone).icon;
710}
711
712function capitalizePhrase(value: string): string {
713 const trimmed = value.trim();
714 if (trimmed.length === 0) {
715 return value;
716 }
717 return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
718}
719
720function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
721 if (!workEntry.toolTitle) {
722 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
723 }
724 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
725}
726
727const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
728 workEntry: TimelineWorkEntry;
729}) {
730 const { workEntry } = props;
731 const iconConfig = workToneIcon(workEntry.tone);
732 const EntryIcon = workEntryIcon(workEntry);
733 const heading = toolWorkEntryHeading(workEntry);
734 const preview = workEntryPreview(workEntry);
735 const displayText = preview ? `${heading} - ${preview}` : heading;
736 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
737 const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;
738
739 return (
740 <div className="rounded-lg px-1 py-1">
741 <div className="flex items-center gap-2 transition-[opacity,translate] duration-200">
742 <span
743 className={cn("flex size-5 shrink-0 items-center justify-center", iconConfig.className)}
744 >
745 <EntryIcon className="size-3" />
746 </span>
747 <div className="min-w-0 flex-1 overflow-hidden">
748 <p
749 className={cn(
750 "truncate text-[11px] leading-5",
751 workToneClass(workEntry.tone),
752 preview ? "text-muted-foreground/70" : "",
753 )}
754 title={displayText}
755 >
756 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
757 {heading}
758 </span>
759 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
760 </p>
761 </div>
762 </div>
763 {hasChangedFiles && !previewIsChangedFiles && (
764 <div className="mt-1 flex flex-wrap gap-1 pl-6">
765 {workEntry.changedFiles?.slice(0, 4).map((filePath) => (
766 <span
767 key={`${workEntry.id}:${filePath}`}
768 className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
769 title={filePath}
770 >
771 {filePath}
772 </span>
773 ))}
774 {(workEntry.changedFiles?.length ?? 0) > 4 && (
775 <span className="px-1 text-[10px] text-muted-foreground/55">
776 +{(workEntry.changedFiles?.length ?? 0) - 4}
777 </span>
778 )}
779 </div>
780 )}
781 </div>
782 );
783});
apps/web/src/session-logic.test.ts+36-3
...@@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => {...@@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => {
400 makeActivity({400 makeActivity({
401 id: "tool-complete",401 id: "tool-complete",
402 createdAt: "2026-02-23T00:00:02.000Z",402 createdAt: "2026-02-23T00:00:02.000Z",
403 summary: "Command run complete",403 summary: "Ran command",
404 tone: "tool",404 tone: "tool",
405 kind: "tool.completed",405 kind: "tool.completed",
406 }),406 }),
...@@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => {...@@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => {
437 makeActivity({437 makeActivity({
438 id: "command-tool",438 id: "command-tool",
439 kind: "tool.completed",439 kind: "tool.completed",
440 summary: "Command run complete",440 summary: "Ran command",
441 payload: {441 payload: {
442 itemType: "command_execution",442 itemType: "command_execution",
443 data: {443 data: {
...@@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => {...@@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => {
453 expect(entry?.command).toBe("bun run lint");453 expect(entry?.command).toBe("bun run lint");
454 });454 });
455455
456 it("keeps compact Codex tool metadata used for icons and labels", () => {
457 const activities: OrchestrationThreadActivity[] = [
458 makeActivity({
459 id: "tool-with-metadata",
460 kind: "tool.completed",
461 summary: "bash",
462 payload: {
463 itemType: "command_execution",
464 title: "bash",
465 status: "completed",
466 detail: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>',
467 data: {
468 item: {
469 command: ["bun", "run", "dev"],
470 result: {
471 content: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>',
472 exitCode: 0,
473 },
474 },
475 },
476 },
477 }),
478 ];
479
480 const [entry] = deriveWorkLogEntries(activities, undefined);
481 expect(entry).toMatchObject({
482 command: "bun run dev",
483 detail: '{ "dev": "vite dev --port 3000" }',
484 itemType: "command_execution",
485 toolTitle: "bash",
486 });
487 });
488
456 it("extracts changed file paths for file-change tool activities", () => {489 it("extracts changed file paths for file-change tool activities", () => {
457 const activities: OrchestrationThreadActivity[] = [490 const activities: OrchestrationThreadActivity[] = [
458 makeActivity({491 makeActivity({
459 id: "file-tool",492 id: "file-tool",
460 kind: "tool.completed",493 kind: "tool.completed",
461 summary: "File change complete",494 summary: "File change",
462 payload: {495 payload: {
463 itemType: "file_change",496 itemType: "file_change",
464 data: {497 data: {
apps/web/src/session-logic.ts+70-3
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import {1import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 isToolLifecycleItemType,
3 type OrchestrationLatestTurn,4 type OrchestrationLatestTurn,
4 type OrchestrationThreadActivity,5 type OrchestrationThreadActivity,
5 type OrchestrationProposedPlanId,6 type OrchestrationProposedPlanId,
6 type ProviderKind,7 type ProviderKind,
8 type ToolLifecycleItemType,
7 type UserInputQuestion,9 type UserInputQuestion,
8 type TurnId,10 type TurnId,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
...@@ -36,6 +38,9 @@ export interface WorkLogEntry {...@@ -36,6 +38,9 @@ export interface WorkLogEntry {
36 command?: string;38 command?: string;
37 changedFiles?: ReadonlyArray<string>;39 changedFiles?: ReadonlyArray<string>;
38 tone: "thinking" | "tool" | "info" | "error";40 tone: "thinking" | "tool" | "info" | "error";
41 toolTitle?: string;
42 itemType?: ToolLifecycleItemType;
43 requestKind?: PendingApproval["requestKind"];
39}44}
4045
41export interface PendingApproval {46export interface PendingApproval {
...@@ -415,14 +420,20 @@ export function deriveWorkLogEntries(...@@ -415,14 +420,20 @@ export function deriveWorkLogEntries(
415 : null;420 : null;
416 const command = extractToolCommand(payload);421 const command = extractToolCommand(payload);
417 const changedFiles = extractChangedFiles(payload);422 const changedFiles = extractChangedFiles(payload);
423 const title = extractToolTitle(payload);
418 const entry: WorkLogEntry = {424 const entry: WorkLogEntry = {
419 id: activity.id,425 id: activity.id,
420 createdAt: activity.createdAt,426 createdAt: activity.createdAt,
421 label: activity.summary,427 label: activity.summary,
422 tone: activity.tone === "approval" ? "info" : activity.tone,428 tone: activity.tone === "approval" ? "info" : activity.tone,
423 };429 };
430 const itemType = extractWorkLogItemType(payload);
431 const requestKind = extractWorkLogRequestKind(payload);
424 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {432 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
425 entry.detail = payload.detail;433 const detail = stripTrailingExitCode(payload.detail).output;
434 if (detail) {
435 entry.detail = detail;
436 }
426 }437 }
427 if (command) {438 if (command) {
428 entry.command = command;439 entry.command = command;
...@@ -430,6 +441,15 @@ export function deriveWorkLogEntries(...@@ -430,6 +441,15 @@ export function deriveWorkLogEntries(
430 if (changedFiles.length > 0) {441 if (changedFiles.length > 0) {
431 entry.changedFiles = changedFiles;442 entry.changedFiles = changedFiles;
432 }443 }
444 if (title) {
445 entry.toolTitle = title;
446 }
447 if (itemType) {
448 entry.itemType = itemType;
449 }
450 if (requestKind) {
451 entry.requestKind = requestKind;
452 }
433 return entry;453 return entry;
434 });454 });
435}455}
...@@ -474,6 +494,53 @@ function extractToolCommand(payload: Record<string, unknown> | null): string | n...@@ -474,6 +494,53 @@ function extractToolCommand(payload: Record<string, unknown> | null): string | n
474 return candidates.find((candidate) => candidate !== null) ?? null;494 return candidates.find((candidate) => candidate !== null) ?? null;
475}495}
476496
497function extractToolTitle(payload: Record<string, unknown> | null): string | null {
498 return asTrimmedString(payload?.title);
499}
500
501function stripTrailingExitCode(value: string): {
502 output: string | null;
503 exitCode?: number | undefined;
504} {
505 const trimmed = value.trim();
506 const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec(
507 trimmed,
508 );
509 if (!match?.groups) {
510 return {
511 output: trimmed.length > 0 ? trimmed : null,
512 };
513 }
514 const exitCode = Number.parseInt(match.groups.code ?? "", 10);
515 const normalizedOutput = match.groups.output?.trim() ?? "";
516 return {
517 output: normalizedOutput.length > 0 ? normalizedOutput : null,
518 ...(Number.isInteger(exitCode) ? { exitCode } : {}),
519 };
520}
521
522function extractWorkLogItemType(
523 payload: Record<string, unknown> | null,
524): WorkLogEntry["itemType"] | undefined {
525 if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) {
526 return payload.itemType;
527 }
528 return undefined;
529}
530
531function extractWorkLogRequestKind(
532 payload: Record<string, unknown> | null,
533): WorkLogEntry["requestKind"] | undefined {
534 if (
535 payload?.requestKind === "command" ||
536 payload?.requestKind === "file-read" ||
537 payload?.requestKind === "file-change"
538 ) {
539 return payload.requestKind;
540 }
541 return requestKindFromRequestType(payload?.requestType) ?? undefined;
542}
543
477function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {544function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
478 const normalized = asTrimmedString(value);545 const normalized = asTrimmedString(value);
479 if (!normalized || seen.has(normalized)) {546 if (!normalized || seen.has(normalized)) {
...@@ -532,9 +599,9 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set<string>...@@ -532,9 +599,9 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set<string>
532}599}
533600
534function extractChangedFiles(payload: Record<string, unknown> | null): string[] {601function extractChangedFiles(payload: Record<string, unknown> | null): string[] {
535 const data = asRecord(payload?.data);
536 const changedFiles: string[] = [];602 const changedFiles: string[] = [];
537 collectChangedFiles(data, changedFiles, new Set<string>(), 0);603 const seen = new Set<string>();
604 collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0);
538 return changedFiles;605 return changedFiles;
539}606}
540607
packages/contracts/src/providerRuntime.ts+16-5
...@@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([...@@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([
93]);93]);
94export type RuntimeErrorClass = typeof RuntimeErrorClass.Type;94export type RuntimeErrorClass = typeof RuntimeErrorClass.Type;
9595
96export const CanonicalItemType = Schema.Literals([96export const TOOL_LIFECYCLE_ITEM_TYPES = [
97 "user_message",
98 "assistant_message",
99 "reasoning",
100 "plan",
101 "command_execution",97 "command_execution",
102 "file_change",98 "file_change",
103 "mcp_tool_call",99 "mcp_tool_call",
...@@ -105,6 +101,21 @@ export const CanonicalItemType = Schema.Literals([...@@ -105,6 +101,21 @@ export const CanonicalItemType = Schema.Literals([
105 "collab_agent_tool_call",101 "collab_agent_tool_call",
106 "web_search",102 "web_search",
107 "image_view",103 "image_view",
104] as const;
105
106export const ToolLifecycleItemType = Schema.Literals(TOOL_LIFECYCLE_ITEM_TYPES);
107export type ToolLifecycleItemType = typeof ToolLifecycleItemType.Type;
108
109export function isToolLifecycleItemType(value: string): value is ToolLifecycleItemType {
110 return TOOL_LIFECYCLE_ITEM_TYPES.includes(value as ToolLifecycleItemType);
111}
112
113export const CanonicalItemType = Schema.Literals([
114 "user_message",
115 "assistant_message",
116 "reasoning",
117 "plan",
118 ...TOOL_LIFECYCLE_ITEM_TYPES,
108 "review_entered",119 "review_entered",
109 "review_exited",120 "review_exited",
110 "context_compaction",121 "context_compaction",