| author | |
| committer | |
| log | 64dc07a8e5a6d7e34de4e85a07126a9c04e462d3 |
| tree | e01144e0650567763005fff3886abcdc66ab2a08 |
| parent | ae4ae9973cd6cebc7d557e64e876534f15c916fd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 73 | turnId: TURN_ID, |
| 74 | 74 | payload: { |
| 75 | 75 | itemType: "command_execution", |
| 76 | title: "Command run", | |
| 76 | title: "Ran command", | |
| 77 | 77 | detail: "echo integration", |
| 78 | 78 | }, |
| 79 | 79 | }, |
| ... | ... | @@ -85,7 +85,7 @@ export const codexTurnToolFixture = [ |
| 85 | 85 | payload: { |
| 86 | 86 | itemType: "command_execution", |
| 87 | 87 | status: "completed", |
| 88 | title: "Command run", | |
| 88 | title: "Ran command", | |
| 89 | 89 | detail: "echo integration", |
| 90 | 90 | }, |
| 91 | 91 | }, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+2-13| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | MessageId, |
| 6 | 6 | type OrchestrationEvent, |
| 7 | 7 | CheckpointRef, |
| 8 | isToolLifecycleItemType, | |
| 8 | 9 | ThreadId, |
| 9 | 10 | TurnId, |
| 10 | 11 | type OrchestrationThreadActivity, |
| ... | ... | @@ -173,18 +174,6 @@ function requestKindFromCanonicalRequestType( |
| 173 | 174 | } |
| 174 | 175 | } |
| 175 | 176 | |
| 176 | function 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 | ||
| 188 | 177 | function runtimeEventToActivities( |
| 189 | 178 | event: ProviderRuntimeEvent, |
| 190 | 179 | ): ReadonlyArray<OrchestrationThreadActivity> { |
| ... | ... | @@ -449,7 +438,7 @@ function runtimeEventToActivities( |
| 449 | 438 | createdAt: event.createdAt, |
| 450 | 439 | tone: "tool", |
| 451 | 440 | kind: "tool.completed", |
| 452 | summary: `${event.payload.title ?? "Tool"} complete`, | |
| 441 | summary: event.payload.title ?? "Tool", | |
| 453 | 442 | payload: { |
| 454 | 443 | itemType: event.payload.itemType, |
| 455 | 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 | 164 | case "plan": |
| 165 | 165 | return "Plan"; |
| 166 | 166 | case "command_execution": |
| 167 | return "Command run"; | |
| 167 | return "Ran command"; | |
| 168 | 168 | case "file_change": |
| 169 | 169 | return "File change"; |
| 170 | 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 | 703 | threadId: session.threadId, |
| 704 | 704 | turnId: asTurnId("turn-1"), |
| 705 | 705 | toolKind: "command", |
| 706 | title: "Command run", | |
| 706 | title: "Ran command", | |
| 707 | 707 | }); |
| 708 | 708 | fanout.codex.emit({ |
| 709 | 709 | type: "tool.completed", |
| ... | ... | @@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 713 | 713 | threadId: session.threadId, |
| 714 | 714 | turnId: asTurnId("turn-1"), |
| 715 | 715 | toolKind: "command", |
| 716 | title: "Command run", | |
| 716 | title: "Ran command", | |
| 717 | 717 | }); |
| 718 | 718 | fanout.codex.emit({ |
| 719 | 719 | type: "turn.completed", |
| ... | ... | @@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 768 | 768 | threadId: session.threadId, |
| 769 | 769 | turnId: asTurnId("turn-1"), |
| 770 | 770 | toolKind: "command", |
| 771 | title: "Command run", | |
| 771 | title: "Ran command", | |
| 772 | 772 | detail: "echo one", |
| 773 | 773 | }, |
| 774 | 774 | { |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+11-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | |
| 2 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; | |
| 3 | 3 | |
| 4 | 4 | describe("computeMessageDurationStart", () => { |
| 5 | 5 | it("returns message createdAt when there is no preceding user message", () => { |
| ... | ... | @@ -133,3 +133,13 @@ describe("computeMessageDurationStart", () => { |
| 133 | 133 | expect(computeMessageDurationStart([])).toEqual(new Map()); |
| 134 | 134 | }); |
| 135 | 135 | }); |
| 136 | ||
| 137 | describe("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 | 23 | |
| 24 | 24 | return result; |
| 25 | 25 | } |
| 26 | ||
| 27 | export 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 | 10 | import { type TurnDiffSummary } from "../../types"; |
| 11 | 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| 12 | 12 | import ChatMarkdown from "../ChatMarkdown"; |
| 13 | import { Undo2Icon } from "lucide-react"; | |
| 13 | import { | |
| 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"; | |
| 14 | 27 | import { Button } from "../ui/button"; |
| 15 | 28 | import { clamp } from "effect/Number"; |
| 16 | 29 | import { estimateTimelineMessageHeight } from "../timelineHeight"; |
| ... | ... | @@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 19 | 32 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 20 | 33 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 21 | 34 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 22 | import { computeMessageDurationStart } from "./MessagesTimeline.logic"; | |
| 35 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; | |
| 36 | import { cn } from "~/lib/utils"; | |
| 23 | 37 | import { type TimestampFormat } from "../../appSettings"; |
| 24 | 38 | import { formatTimestamp } from "../../timestampFormat"; |
| 25 | 39 | |
| ... | ... | @@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 289 | 303 | : groupedEntries; |
| 290 | 304 | const hiddenCount = groupedEntries.length - visibleEntries.length; |
| 291 | 305 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); |
| 292 | const groupLabel = onlyToolEntries | |
| 293 | ? groupedEntries.length === 1 | |
| 294 | ? "Tool call" | |
| 295 | : `Tool calls (${groupedEntries.length})` | |
| 296 | : groupedEntries.length === 1 | |
| 297 | ? "Work event" | |
| 298 | : `Work log (${groupedEntries.length})`; | |
| 306 | const showHeader = hasOverflow || !onlyToolEntries; | |
| 307 | const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; | |
| 299 | 308 | |
| 300 | 309 | return ( |
| 301 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 302 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 303 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 304 | {groupLabel} | |
| 305 | </p> | |
| 306 | {hasOverflow && ( | |
| 307 | <button | |
| 308 | type="button" | |
| 309 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 310 | onClick={() => onToggleWorkGroup(groupId)} | |
| 311 | > | |
| 312 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 313 | </button> | |
| 314 | )} | |
| 315 | </div> | |
| 316 | <div className="space-y-1"> | |
| 310 | <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5"> | |
| 311 | {showHeader && ( | |
| 312 | <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5"> | |
| 313 | <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55"> | |
| 314 | {groupLabel} ({groupedEntries.length}) | |
| 315 | </p> | |
| 316 | {hasOverflow && ( | |
| 317 | <button | |
| 318 | type="button" | |
| 319 | className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75" | |
| 320 | onClick={() => onToggleWorkGroup(groupId)} | |
| 321 | > | |
| 322 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 323 | </button> | |
| 324 | )} | |
| 325 | </div> | |
| 326 | )} | |
| 327 | <div className="space-y-0.5"> | |
| 317 | 328 | {visibleEntries.map((workEntry) => ( |
| 318 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | |
| 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> | |
| 329 | <SimpleWorkEntryRow key={`work-row:${workEntry.id}`} workEntry={workEntry} /> | |
| 358 | 330 | ))} |
| 359 | 331 | </div> |
| 360 | 332 | </div> |
| ... | ... | @@ -664,9 +636,148 @@ function formatMessageMeta( |
| 664 | 636 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; |
| 665 | 637 | } |
| 666 | 638 | |
| 639 | function 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 | ||
| 667 | 667 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 668 | 668 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| 669 | 669 | if (tone === "tool") return "text-muted-foreground/70"; |
| 670 | 670 | if (tone === "thinking") return "text-muted-foreground/50"; |
| 671 | 671 | return "text-muted-foreground/40"; |
| 672 | 672 | } |
| 673 | ||
| 674 | function 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 | ||
| 687 | function 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 | ||
| 712 | function 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 | ||
| 720 | function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { | |
| 721 | if (!workEntry.toolTitle) { | |
| 722 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); | |
| 723 | } | |
| 724 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); | |
| 725 | } | |
| 726 | ||
| 727 | const 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 | 400 | makeActivity({ |
| 401 | 401 | id: "tool-complete", |
| 402 | 402 | createdAt: "2026-02-23T00:00:02.000Z", |
| 403 | summary: "Command run complete", | |
| 403 | summary: "Ran command", | |
| 404 | 404 | tone: "tool", |
| 405 | 405 | kind: "tool.completed", |
| 406 | 406 | }), |
| ... | ... | @@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => { |
| 437 | 437 | makeActivity({ |
| 438 | 438 | id: "command-tool", |
| 439 | 439 | kind: "tool.completed", |
| 440 | summary: "Command run complete", | |
| 440 | summary: "Ran command", | |
| 441 | 441 | payload: { |
| 442 | 442 | itemType: "command_execution", |
| 443 | 443 | data: { |
| ... | ... | @@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => { |
| 453 | 453 | expect(entry?.command).toBe("bun run lint"); |
| 454 | 454 | }); |
| 455 | 455 | |
| 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 | 489 | it("extracts changed file paths for file-change tool activities", () => { |
| 457 | 490 | const activities: OrchestrationThreadActivity[] = [ |
| 458 | 491 | makeActivity({ |
| 459 | 492 | id: "file-tool", |
| 460 | 493 | kind: "tool.completed", |
| 461 | summary: "File change complete", | |
| 494 | summary: "File change", | |
| 462 | 495 | payload: { |
| 463 | 496 | itemType: "file_change", |
| 464 | 497 | data: { |
apps/web/src/session-logic.ts+70-3| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | import { |
| 2 | 2 | ApprovalRequestId, |
| 3 | isToolLifecycleItemType, | |
| 3 | 4 | type OrchestrationLatestTurn, |
| 4 | 5 | type OrchestrationThreadActivity, |
| 5 | 6 | type OrchestrationProposedPlanId, |
| 6 | 7 | type ProviderKind, |
| 8 | type ToolLifecycleItemType, | |
| 7 | 9 | type UserInputQuestion, |
| 8 | 10 | type TurnId, |
| 9 | 11 | } from "@t3tools/contracts"; |
| ... | ... | @@ -36,6 +38,9 @@ export interface WorkLogEntry { |
| 36 | 38 | command?: string; |
| 37 | 39 | changedFiles?: ReadonlyArray<string>; |
| 38 | 40 | tone: "thinking" | "tool" | "info" | "error"; |
| 41 | toolTitle?: string; | |
| 42 | itemType?: ToolLifecycleItemType; | |
| 43 | requestKind?: PendingApproval["requestKind"]; | |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | 46 | export interface PendingApproval { |
| ... | ... | @@ -415,14 +420,20 @@ export function deriveWorkLogEntries( |
| 415 | 420 | : null; |
| 416 | 421 | const command = extractToolCommand(payload); |
| 417 | 422 | const changedFiles = extractChangedFiles(payload); |
| 423 | const title = extractToolTitle(payload); | |
| 418 | 424 | const entry: WorkLogEntry = { |
| 419 | 425 | id: activity.id, |
| 420 | 426 | createdAt: activity.createdAt, |
| 421 | 427 | label: activity.summary, |
| 422 | 428 | tone: activity.tone === "approval" ? "info" : activity.tone, |
| 423 | 429 | }; |
| 430 | const itemType = extractWorkLogItemType(payload); | |
| 431 | const requestKind = extractWorkLogRequestKind(payload); | |
| 424 | 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 | 438 | if (command) { |
| 428 | 439 | entry.command = command; |
| ... | ... | @@ -430,6 +441,15 @@ export function deriveWorkLogEntries( |
| 430 | 441 | if (changedFiles.length > 0) { |
| 431 | 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 | 453 | return entry; |
| 434 | 454 | }); |
| 435 | 455 | } |
| ... | ... | @@ -474,6 +494,53 @@ function extractToolCommand(payload: Record<string, unknown> | null): string | n |
| 474 | 494 | return candidates.find((candidate) => candidate !== null) ?? null; |
| 475 | 495 | } |
| 476 | 496 | |
| 497 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | |
| 498 | return asTrimmedString(payload?.title); | |
| 499 | } | |
| 500 | ||
| 501 | function 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 | ||
| 522 | function 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 | ||
| 531 | function 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 | ||
| 477 | 544 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { |
| 478 | 545 | const normalized = asTrimmedString(value); |
| 479 | 546 | if (!normalized || seen.has(normalized)) { |
| ... | ... | @@ -532,9 +599,9 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set<string> |
| 532 | 599 | } |
| 533 | 600 | |
| 534 | 601 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { |
| 535 | const data = asRecord(payload?.data); | |
| 536 | 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 | 605 | return changedFiles; |
| 539 | 606 | } |
| 540 | 607 |
packages/contracts/src/providerRuntime.ts+16-5| ... | ... | @@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([ |
| 93 | 93 | ]); |
| 94 | 94 | export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; |
| 95 | 95 | |
| 96 | export const CanonicalItemType = Schema.Literals([ | |
| 97 | "user_message", | |
| 98 | "assistant_message", | |
| 99 | "reasoning", | |
| 100 | "plan", | |
| 96 | export const TOOL_LIFECYCLE_ITEM_TYPES = [ | |
| 101 | 97 | "command_execution", |
| 102 | 98 | "file_change", |
| 103 | 99 | "mcp_tool_call", |
| ... | ... | @@ -105,6 +101,21 @@ export const CanonicalItemType = Schema.Literals([ |
| 105 | 101 | "collab_agent_tool_call", |
| 106 | 102 | "web_search", |
| 107 | 103 | "image_view", |
| 104 | ] as const; | |
| 105 | ||
| 106 | export const ToolLifecycleItemType = Schema.Literals(TOOL_LIFECYCLE_ITEM_TYPES); | |
| 107 | export type ToolLifecycleItemType = typeof ToolLifecycleItemType.Type; | |
| 108 | ||
| 109 | export function isToolLifecycleItemType(value: string): value is ToolLifecycleItemType { | |
| 110 | return TOOL_LIFECYCLE_ITEM_TYPES.includes(value as ToolLifecycleItemType); | |
| 111 | } | |
| 112 | ||
| 113 | export const CanonicalItemType = Schema.Literals([ | |
| 114 | "user_message", | |
| 115 | "assistant_message", | |
| 116 | "reasoning", | |
| 117 | "plan", | |
| 118 | ...TOOL_LIFECYCLE_ITEM_TYPES, | |
| 108 | 119 | "review_entered", |
| 109 | 120 | "review_exited", |
| 110 | 121 | "context_compaction", |