| author | |
| committer | |
| log | ac11bd29b03ec568f2616c78d885574eff16ad3c |
| tree | 98f96ac22b0e8de67c4ed6be8566e4c72185bdbd |
| parent | dd64072917193195479f6907cfb081267bff7301 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 259 insertions(+), 341 deletions(-)
apps/mobile/src/lib/threadActivity.ts+5-84| ... | ... | @@ -16,6 +16,7 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 16 | 16 | import { |
| 17 | 17 | commandDetailRepeatsCommand, |
| 18 | 18 | extractCommandOutputText, |
| 19 | extractWorkLogToolLifecycleStatus, | |
| 19 | 20 | isWorktreeSetupActivity, |
| 20 | 21 | liveActivityToolStatus, |
| 21 | 22 | normalizeCompactToolLabel, |
| ... | ... | @@ -24,7 +25,11 @@ import { |
| 24 | 25 | summarizeToolGroup, |
| 25 | 26 | toolGroupAction, |
| 26 | 27 | toolGroupSummaryKind, |
| 28 | workEntryIndicatesToolFailure, | |
| 29 | workEntryIndicatesToolSuccess, | |
| 30 | workLogEntryIsToolLike, | |
| 27 | 31 | type ToolGroupSummaryKind, |
| 32 | type WorkLogToolLifecycleStatus, | |
| 28 | 33 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 29 | 34 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 30 | 35 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| ... | ... | @@ -88,8 +93,6 @@ export interface ThreadFeedActivity { |
| 88 | 93 | readonly live?: boolean; |
| 89 | 94 | } |
| 90 | 95 | |
| 91 | type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; | |
| 92 | ||
| 93 | 96 | export interface WorkLogEntry { |
| 94 | 97 | id: string; |
| 95 | 98 | createdAt: string; |
| ... | ... | @@ -989,67 +992,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un |
| 989 | 992 | return [itemType, normalizedLabel, detail].join("\u001f"); |
| 990 | 993 | } |
| 991 | 994 | |
| 992 | function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | |
| 993 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | |
| 994 | return true; | |
| 995 | } | |
| 996 | if (entry.command !== undefined && entry.command.trim().length > 0) { | |
| 997 | return true; | |
| 998 | } | |
| 999 | if (entry.requestKind !== undefined) { | |
| 1000 | return true; | |
| 1001 | } | |
| 1002 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | |
| 1003 | } | |
| 1004 | ||
| 1005 | function toolDetailTextLooksLikeFailure(text: string): boolean { | |
| 1006 | const normalized = text.toLowerCase(); | |
| 1007 | return ( | |
| 1008 | normalized.includes("file not found") || | |
| 1009 | normalized.includes("no files found") || | |
| 1010 | normalized.includes("enoent") || | |
| 1011 | normalized.includes("no such file or directory") || | |
| 1012 | normalized.includes("no such file") || | |
| 1013 | normalized.includes("commandnotfoundexception") || | |
| 1014 | normalized.includes("command not found") || | |
| 1015 | (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || | |
| 1016 | (normalized.includes("is not recognized") && normalized.includes("the term '")) || | |
| 1017 | normalized.includes("is not recognized as the name of a cmdlet") || | |
| 1018 | normalized.includes("a parameter cannot be found that matches parameter name") || | |
| 1019 | /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) || | |
| 1020 | /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || | |
| 1021 | /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) | |
| 1022 | ); | |
| 1023 | } | |
| 1024 | ||
| 1025 | function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | |
| 1026 | if (entry.tone === "error") { | |
| 1027 | return true; | |
| 1028 | } | |
| 1029 | if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { | |
| 1030 | return true; | |
| 1031 | } | |
| 1032 | if (!workLogEntryIsToolLike(entry)) { | |
| 1033 | return false; | |
| 1034 | } | |
| 1035 | return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); | |
| 1036 | } | |
| 1037 | ||
| 1038 | function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | |
| 1039 | if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { | |
| 1040 | return false; | |
| 1041 | } | |
| 1042 | if (entry.tone === "thinking") { | |
| 1043 | return false; | |
| 1044 | } | |
| 1045 | return ( | |
| 1046 | entry.toolLifecycleStatus !== "inProgress" && | |
| 1047 | entry.toolLifecycleStatus !== "stopped" && | |
| 1048 | entry.toolLifecycleStatus !== "failed" && | |
| 1049 | entry.toolLifecycleStatus !== "declined" | |
| 1050 | ); | |
| 1051 | } | |
| 1052 | ||
| 1053 | 995 | function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { |
| 1054 | 996 | if (entry.agentSpawn) { |
| 1055 | 997 | switch (entry.toolLifecycleStatus) { |
| ... | ... | @@ -1521,27 +1463,6 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul |
| 1521 | 1463 | return asTrimmedString(payload?.title); |
| 1522 | 1464 | } |
| 1523 | 1465 | |
| 1524 | function extractWorkLogToolLifecycleStatus( | |
| 1525 | payload: Record<string, unknown> | null, | |
| 1526 | ): WorkLogToolLifecycleStatus | undefined { | |
| 1527 | const status = payload?.status; | |
| 1528 | // The parent turn ended, so batch tracking is inactive. The detail explains | |
| 1529 | // that child status is unavailable; do not retain the earlier running marker. | |
| 1530 | if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped"; | |
| 1531 | if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; | |
| 1532 | if (status === "cancelled" || status === "interrupted") return "stopped"; | |
| 1533 | if ( | |
| 1534 | status === "inProgress" || | |
| 1535 | status === "completed" || | |
| 1536 | status === "failed" || | |
| 1537 | status === "declined" || | |
| 1538 | status === "stopped" | |
| 1539 | ) { | |
| 1540 | return status; | |
| 1541 | } | |
| 1542 | return undefined; | |
| 1543 | } | |
| 1544 | ||
| 1545 | 1466 | function stripTrailingExitCode(value: string): { |
| 1546 | 1467 | output: string | null; |
| 1547 | 1468 | exitCode?: number | undefined; |
apps/web/src/session-logic.test.ts+30-110| ... | ... | @@ -23,9 +23,7 @@ import { |
| 23 | 23 | isLatestTurnSettled, |
| 24 | 24 | selectHandoffImageResources, |
| 25 | 25 | selectMessageImageResources, |
| 26 | workEntryIndicatesToolFailure, | |
| 27 | 26 | workEntryIndicatesToolNeutralStatus, |
| 28 | workEntryIndicatesToolSuccess, | |
| 29 | 27 | } from "./session-logic"; |
| 30 | 28 | |
| 31 | 29 | let nextActivityId = 0; |
| ... | ... | @@ -809,121 +807,43 @@ describe("hasActionableProposedPlan", () => { |
| 809 | 807 | }); |
| 810 | 808 | }); |
| 811 | 809 | |
| 812 | describe("workEntryIndicatesToolFailure", () => { | |
| 813 | const base = { | |
| 814 | id: "w1", | |
| 815 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 816 | label: "Read", | |
| 817 | }; | |
| 818 | ||
| 819 | it("is true for error tone", () => { | |
| 820 | expect( | |
| 821 | workEntryIndicatesToolFailure({ | |
| 822 | ...base, | |
| 823 | tone: "error", | |
| 824 | detail: "nothing special", | |
| 825 | }), | |
| 826 | ).toBe(true); | |
| 827 | }); | |
| 828 | ||
| 829 | it("is true when lifecycle says failed even if detail is empty", () => { | |
| 830 | expect( | |
| 831 | workEntryIndicatesToolFailure({ | |
| 832 | ...base, | |
| 833 | tone: "tool", | |
| 834 | toolLifecycleStatus: "failed", | |
| 835 | }), | |
| 836 | ).toBe(true); | |
| 837 | }); | |
| 838 | ||
| 839 | it("detects file-not-found style tool output with completed lifecycle", () => { | |
| 840 | expect( | |
| 841 | workEntryIndicatesToolFailure({ | |
| 842 | ...base, | |
| 843 | tone: "tool", | |
| 844 | toolLifecycleStatus: "completed", | |
| 845 | detail: "File not found: C:\\foo\\nonexistent.ts", | |
| 846 | }), | |
| 847 | ).toBe(true); | |
| 848 | }); | |
| 849 | ||
| 850 | it("detects glob no files and PowerShell command errors", () => { | |
| 851 | expect( | |
| 852 | workEntryIndicatesToolFailure({ | |
| 853 | ...base, | |
| 854 | label: "Glob", | |
| 855 | tone: "tool", | |
| 856 | detail: "No files found", | |
| 857 | }), | |
| 858 | ).toBe(true); | |
| 859 | expect( | |
| 860 | workEntryIndicatesToolFailure({ | |
| 861 | ...base, | |
| 862 | label: "Bash", | |
| 863 | tone: "tool", | |
| 864 | detail: | |
| 865 | "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", | |
| 866 | }), | |
| 867 | ).toBe(true); | |
| 868 | }); | |
| 869 | ||
| 870 | it("is false for successful completed tools", () => { | |
| 871 | expect( | |
| 872 | workEntryIndicatesToolFailure({ | |
| 873 | ...base, | |
| 874 | tone: "tool", | |
| 875 | toolLifecycleStatus: "completed", | |
| 876 | detail: "Found 3 matching files", | |
| 877 | }), | |
| 878 | ).toBe(false); | |
| 879 | }); | |
| 880 | ||
| 881 | it("treats successful tool rows as success candidates", () => { | |
| 882 | expect( | |
| 883 | workEntryIndicatesToolSuccess({ | |
| 884 | ...base, | |
| 885 | tone: "tool", | |
| 886 | toolLifecycleStatus: "completed", | |
| 887 | detail: "ok", | |
| 888 | }), | |
| 889 | ).toBe(true); | |
| 890 | expect( | |
| 891 | workEntryIndicatesToolSuccess({ | |
| 892 | ...base, | |
| 893 | tone: "tool", | |
| 894 | toolLifecycleStatus: "inProgress", | |
| 895 | detail: "…", | |
| 896 | }), | |
| 897 | ).toBe(false); | |
| 898 | expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); | |
| 899 | expect( | |
| 900 | workEntryIndicatesToolNeutralStatus({ | |
| 901 | ...base, | |
| 902 | tone: "tool", | |
| 903 | toolLifecycleStatus: "inProgress", | |
| 904 | detail: "…", | |
| 905 | }), | |
| 906 | ).toBe(true); | |
| 810 | describe("workEntryIndicatesToolNeutralStatus", () => { | |
| 811 | it("keeps active tools neutral and agent spawns visible", () => { | |
| 812 | const entry = { | |
| 813 | id: "work-1", | |
| 814 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 815 | label: "Read", | |
| 816 | tone: "tool" as const, | |
| 817 | toolLifecycleStatus: "inProgress" as const, | |
| 818 | }; | |
| 819 | expect(workEntryIndicatesToolNeutralStatus(entry)).toBe(true); | |
| 907 | 820 | expect( |
| 908 | 821 | workEntryIndicatesToolNeutralStatus({ |
| 909 | ...base, | |
| 910 | tone: "tool", | |
| 911 | toolLifecycleStatus: "completed", | |
| 912 | detail: "ok", | |
| 822 | ...entry, | |
| 823 | agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] }, | |
| 913 | 824 | }), |
| 914 | 825 | ).toBe(false); |
| 915 | }); | |
| 916 | ||
| 917 | it("does not run heuristics on non-tool info rows", () => { | |
| 918 | 826 | expect( |
| 919 | workEntryIndicatesToolFailure({ | |
| 920 | ...base, | |
| 921 | label: "Context compacted", | |
| 922 | tone: "info", | |
| 923 | detail: "File not found in conversation", | |
| 924 | }), | |
| 827 | workEntryIndicatesToolNeutralStatus({ ...entry, toolLifecycleStatus: "completed" }), | |
| 925 | 828 | ).toBe(false); |
| 926 | 829 | }); |
| 830 | ||
| 831 | it.each(["waiting", "cancelled", "interrupted"])( | |
| 832 | "keeps the status of a %s background task", | |
| 833 | (status) => { | |
| 834 | const entries = deriveWorkLogEntries([ | |
| 835 | makeActivity({ | |
| 836 | kind: "task.progress", | |
| 837 | payload: { taskId: "background-1", agentKind: "background", status }, | |
| 838 | }), | |
| 839 | ]); | |
| 840 | expect(entries).toHaveLength(1); | |
| 841 | expect(entries[0]).toMatchObject({ | |
| 842 | toolLifecycleStatus: status === "waiting" ? "inProgress" : "stopped", | |
| 843 | }); | |
| 844 | expect(workEntryIndicatesToolNeutralStatus(entries[0]!)).toBe(true); | |
| 845 | }, | |
| 846 | ); | |
| 927 | 847 | }); |
| 928 | 848 | |
| 929 | 849 | describe("deriveWorkLogEntries", () => { |
apps/web/src/session-logic.ts+11-146| ... | ... | @@ -6,7 +6,12 @@ import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagent |
| 6 | 6 | import { |
| 7 | 7 | commandDetailRepeatsCommand, |
| 8 | 8 | extractCommandOutputText, |
| 9 | extractWorkLogToolLifecycleStatus, | |
| 9 | 10 | isWorktreeSetupActivity, |
| 11 | workEntryIndicatesToolFailure, | |
| 12 | workEntryIndicatesToolSuccess, | |
| 13 | workLogEntryIsToolLike, | |
| 14 | type WorkLogToolLifecycleStatus, | |
| 10 | 15 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 11 | 16 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 12 | 17 | import { |
| ... | ... | @@ -37,12 +42,12 @@ import { |
| 37 | 42 | |
| 38 | 43 | export { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 39 | 44 | |
| 40 | export type WorkLogToolLifecycleStatus = | |
| 41 | | "inProgress" | |
| 42 | | "completed" | |
| 43 | | "failed" | |
| 44 | | "declined" | |
| 45 | | "stopped"; | |
| 45 | export { | |
| 46 | workEntryDisplayIndicatesToolFailure, | |
| 47 | workEntryIndicatesToolSuccess, | |
| 48 | workLogEntryIsToolLike, | |
| 49 | type WorkLogToolLifecycleStatus, | |
| 50 | } from "@t3tools/client-runtime/work-log/presentation"; | |
| 46 | 51 | |
| 47 | 52 | export interface WorkLogEntry { |
| 48 | 53 | id: string; |
| ... | ... | @@ -167,103 +172,6 @@ export interface TimelineEntriesProjection { |
| 167 | 172 | readonly entries: TimelineEntry[]; |
| 168 | 173 | } |
| 169 | 174 | |
| 170 | export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { | |
| 171 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { | |
| 172 | return true; | |
| 173 | } | |
| 174 | if (entry.command !== undefined && entry.command.trim().length > 0) { | |
| 175 | return true; | |
| 176 | } | |
| 177 | if (entry.requestKind !== undefined) { | |
| 178 | return true; | |
| 179 | } | |
| 180 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | |
| 181 | } | |
| 182 | ||
| 183 | /** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */ | |
| 184 | function toolDetailTextLooksLikeFailure(text: string): boolean { | |
| 185 | const t = text.toLowerCase(); | |
| 186 | if (t.includes("file not found")) { | |
| 187 | return true; | |
| 188 | } | |
| 189 | if (t.includes("no files found")) { | |
| 190 | return true; | |
| 191 | } | |
| 192 | if ( | |
| 193 | t.includes("enoent") || | |
| 194 | t.includes("no such file or directory") || | |
| 195 | t.includes("no such file") | |
| 196 | ) { | |
| 197 | return true; | |
| 198 | } | |
| 199 | if (t.includes("cannot find path") && t.includes("because it does not exist")) { | |
| 200 | return true; | |
| 201 | } | |
| 202 | if (t.includes("commandnotfoundexception")) { | |
| 203 | return true; | |
| 204 | } | |
| 205 | if (t.includes("is not recognized as the name of a cmdlet")) { | |
| 206 | return true; | |
| 207 | } | |
| 208 | if (t.includes("is not recognized") && t.includes("the term '")) { | |
| 209 | return true; | |
| 210 | } | |
| 211 | if (t.includes("a parameter cannot be found that matches parameter name")) { | |
| 212 | return true; | |
| 213 | } | |
| 214 | if (t.includes("command not found")) { | |
| 215 | return true; | |
| 216 | } | |
| 217 | if (/<exited with exit code\s+[1-9]\d*\s*>/i.test(text)) { | |
| 218 | return true; | |
| 219 | } | |
| 220 | if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) { | |
| 221 | return true; | |
| 222 | } | |
| 223 | if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) { | |
| 224 | return true; | |
| 225 | } | |
| 226 | return false; | |
| 227 | } | |
| 228 | ||
| 229 | function workEntryIndicatesToolFailureFromOutput( | |
| 230 | entry: WorkLogEntry, | |
| 231 | includeCommand: boolean, | |
| 232 | ): boolean { | |
| 233 | if (entry.tone === "error") { | |
| 234 | return true; | |
| 235 | } | |
| 236 | const ls = entry.toolLifecycleStatus; | |
| 237 | if (ls === "failed" || ls === "declined") { | |
| 238 | return true; | |
| 239 | } | |
| 240 | if (!workLogEntryIsToolLike(entry)) { | |
| 241 | return false; | |
| 242 | } | |
| 243 | const parts: string[] = []; | |
| 244 | if (entry.detail) { | |
| 245 | parts.push(entry.detail); | |
| 246 | } | |
| 247 | if (includeCommand && entry.command) { | |
| 248 | parts.push(entry.command); | |
| 249 | } | |
| 250 | const blob = parts.join("\n"); | |
| 251 | if (blob.length === 0) { | |
| 252 | return false; | |
| 253 | } | |
| 254 | return toolDetailTextLooksLikeFailure(blob); | |
| 255 | } | |
| 256 | ||
| 257 | /** True when a tool failed, including providers that put error output in `command`. */ | |
| 258 | export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { | |
| 259 | return workEntryIndicatesToolFailureFromOutput(entry, true); | |
| 260 | } | |
| 261 | ||
| 262 | /** True when the rendered result indicates failure. The command itself is user intent, not output. */ | |
| 263 | export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean { | |
| 264 | return workEntryIndicatesToolFailureFromOutput(entry, false); | |
| 265 | } | |
| 266 | ||
| 267 | 175 | /** Severe failures keep the red treatment ordinary tool failures lost: runtime |
| 268 | 176 | * errors and orchestration `*.failed` activities (provider.turn.start.failed, |
| 269 | 177 | * checkpoint.capture.failed, ...) mean the turn or a core side effect broke, |
| ... | ... | @@ -275,30 +183,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { |
| 275 | 183 | ); |
| 276 | 184 | } |
| 277 | 185 | |
| 278 | /** Tool/command row completed without failure (blue check affordance). */ | |
| 279 | export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | |
| 280 | if (!workLogEntryIsToolLike(entry)) { | |
| 281 | return false; | |
| 282 | } | |
| 283 | if (workEntryIndicatesToolFailure(entry)) { | |
| 284 | return false; | |
| 285 | } | |
| 286 | if (entry.tone === "thinking") { | |
| 287 | return false; | |
| 288 | } | |
| 289 | const ls = entry.toolLifecycleStatus; | |
| 290 | if (ls === "failed" || ls === "declined") { | |
| 291 | return false; | |
| 292 | } | |
| 293 | if (ls === "inProgress") { | |
| 294 | return false; | |
| 295 | } | |
| 296 | if (ls === "stopped") { | |
| 297 | return false; | |
| 298 | } | |
| 299 | return true; | |
| 300 | } | |
| 301 | ||
| 302 | 186 | /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ |
| 303 | 187 | export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { |
| 304 | 188 | // Spawn CTA rows are never neutral-hidden: mid-run they derive from |
| ... | ... | @@ -827,25 +711,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool |
| 827 | 711 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); |
| 828 | 712 | } |
| 829 | 713 | |
| 830 | function extractWorkLogToolLifecycleStatus( | |
| 831 | payload: Record<string, unknown> | null, | |
| 832 | ): WorkLogToolLifecycleStatus | undefined { | |
| 833 | if (!payload) { | |
| 834 | return undefined; | |
| 835 | } | |
| 836 | const s = payload.status; | |
| 837 | if ( | |
| 838 | s === "inProgress" || | |
| 839 | s === "completed" || | |
| 840 | s === "failed" || | |
| 841 | s === "declined" || | |
| 842 | s === "stopped" | |
| 843 | ) { | |
| 844 | return s; | |
| 845 | } | |
| 846 | return undefined; | |
| 847 | } | |
| 848 | ||
| 849 | 714 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { |
| 850 | 715 | const cachedEntry = derivedWorkLogEntryByActivity.get(activity); |
| 851 | 716 | if (cachedEntry) { |
packages/client-runtime/src/work-log/presentation.test.ts+122| ... | ... | @@ -12,8 +12,130 @@ import { |
| 12 | 12 | toolGroupSummaryKind, |
| 13 | 13 | type WorkLogPresentationEntry, |
| 14 | 14 | workEntryViewedImagePath, |
| 15 | workEntryIndicatesToolFailure, | |
| 16 | workEntryDisplayIndicatesToolFailure, | |
| 17 | workEntryIndicatesToolSuccess, | |
| 15 | 18 | } from "./presentation.js"; |
| 16 | 19 | |
| 20 | describe("workEntryIndicatesToolFailure", () => { | |
| 21 | const base = { | |
| 22 | id: "w1", | |
| 23 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 24 | label: "Read", | |
| 25 | }; | |
| 26 | ||
| 27 | it("is true for error tone", () => { | |
| 28 | expect( | |
| 29 | workEntryIndicatesToolFailure({ | |
| 30 | ...base, | |
| 31 | tone: "error", | |
| 32 | detail: "nothing special", | |
| 33 | }), | |
| 34 | ).toBe(true); | |
| 35 | }); | |
| 36 | ||
| 37 | it("is true when lifecycle says failed even if detail is empty", () => { | |
| 38 | expect( | |
| 39 | workEntryIndicatesToolFailure({ | |
| 40 | ...base, | |
| 41 | tone: "tool", | |
| 42 | toolLifecycleStatus: "failed", | |
| 43 | }), | |
| 44 | ).toBe(true); | |
| 45 | }); | |
| 46 | ||
| 47 | it("detects file-not-found style tool output with completed lifecycle", () => { | |
| 48 | expect( | |
| 49 | workEntryIndicatesToolFailure({ | |
| 50 | ...base, | |
| 51 | tone: "tool", | |
| 52 | toolLifecycleStatus: "completed", | |
| 53 | detail: "File not found: C:\\foo\\nonexistent.ts", | |
| 54 | }), | |
| 55 | ).toBe(true); | |
| 56 | }); | |
| 57 | ||
| 58 | it("detects glob no files and PowerShell command errors", () => { | |
| 59 | expect( | |
| 60 | workEntryIndicatesToolFailure({ | |
| 61 | ...base, | |
| 62 | label: "Glob", | |
| 63 | tone: "tool", | |
| 64 | detail: "No files found", | |
| 65 | }), | |
| 66 | ).toBe(true); | |
| 67 | expect( | |
| 68 | workEntryIndicatesToolFailure({ | |
| 69 | ...base, | |
| 70 | label: "Bash", | |
| 71 | tone: "tool", | |
| 72 | detail: | |
| 73 | "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", | |
| 74 | }), | |
| 75 | ).toBe(true); | |
| 76 | }); | |
| 77 | ||
| 78 | it("is false for successful completed tools", () => { | |
| 79 | expect( | |
| 80 | workEntryIndicatesToolFailure({ | |
| 81 | ...base, | |
| 82 | tone: "tool", | |
| 83 | toolLifecycleStatus: "completed", | |
| 84 | detail: "Found 3 matching files", | |
| 85 | }), | |
| 86 | ).toBe(false); | |
| 87 | }); | |
| 88 | ||
| 89 | it("does not treat error text in a command as rendered failure", () => { | |
| 90 | const entry = { | |
| 91 | label: "Ran command", | |
| 92 | tone: "tool", | |
| 93 | toolLifecycleStatus: "completed", | |
| 94 | command: 'rg "file not found"', | |
| 95 | detail: "Found 3 matches", | |
| 96 | } satisfies WorkLogPresentationEntry; | |
| 97 | ||
| 98 | expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); | |
| 99 | // Older activities can store output in this field, so that path stays separate. | |
| 100 | expect(workEntryIndicatesToolFailure(entry)).toBe(true); | |
| 101 | expect(workEntryDisplayIndicatesToolFailure({ ...entry, detail: "File not found" })).toBe(true); | |
| 102 | }); | |
| 103 | ||
| 104 | it("treats successful tool rows as success candidates", () => { | |
| 105 | expect( | |
| 106 | workEntryIndicatesToolSuccess({ | |
| 107 | ...base, | |
| 108 | tone: "tool", | |
| 109 | toolLifecycleStatus: "completed", | |
| 110 | detail: "ok", | |
| 111 | }), | |
| 112 | ).toBe(true); | |
| 113 | expect( | |
| 114 | workEntryIndicatesToolSuccess({ | |
| 115 | ...base, | |
| 116 | tone: "tool", | |
| 117 | toolLifecycleStatus: "inProgress", | |
| 118 | detail: "…", | |
| 119 | }), | |
| 120 | ).toBe(false); | |
| 121 | expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); | |
| 122 | expect( | |
| 123 | workEntryIndicatesToolSuccess({ ...base, tone: "tool", toolLifecycleStatus: "stopped" }), | |
| 124 | ).toBe(false); | |
| 125 | }); | |
| 126 | ||
| 127 | it("does not run heuristics on non-tool info rows", () => { | |
| 128 | expect( | |
| 129 | workEntryIndicatesToolFailure({ | |
| 130 | ...base, | |
| 131 | label: "Context compacted", | |
| 132 | tone: "info", | |
| 133 | detail: "File not found in conversation", | |
| 134 | }), | |
| 135 | ).toBe(false); | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 17 | 139 | describe("summarizeToolGroup", () => { |
| 18 | 140 | it.each(["command", "file-read", "file-change"])( |
| 19 | 141 | "keeps %s approvals out of tool execution counts", |
packages/client-runtime/src/work-log/presentation.ts+91-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | isToolLifecycleItemType, |
| 3 | 3 | type AssetResource, |
| 4 | type RuntimeItemStatus, | |
| 4 | 5 | type ThreadId, |
| 5 | 6 | type ToolActivitySource, |
| 6 | 7 | type ToolLifecycleItemType, |
| ... | ... | @@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean { |
| 13 | 14 | return kind === "setup-script.requested" || kind === "setup-script.started"; |
| 14 | 15 | } |
| 15 | 16 | |
| 17 | export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped"; | |
| 18 | ||
| 16 | 19 | export interface WorkLogPresentationEntry { |
| 17 | 20 | readonly label: string; |
| 18 | 21 | readonly toolTitle?: string; |
| ... | ... | @@ -298,13 +301,100 @@ export function commandDetailRepeatsCommand(input: { |
| 298 | 301 | ); |
| 299 | 302 | } |
| 300 | 303 | |
| 301 | function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { | |
| 304 | export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { | |
| 302 | 305 | if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; |
| 303 | 306 | if (entry.command !== undefined && entry.command.trim().length > 0) return true; |
| 304 | 307 | if (entry.requestKind !== undefined) return true; |
| 305 | 308 | return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); |
| 306 | 309 | } |
| 307 | 310 | |
| 311 | /** Maps item and task status to the status shown on a work-log row. */ | |
| 312 | export function extractWorkLogToolLifecycleStatus( | |
| 313 | payloadValue: unknown, | |
| 314 | ): WorkLogToolLifecycleStatus | undefined { | |
| 315 | const payload = asRecord(payloadValue); | |
| 316 | switch (payload?.status) { | |
| 317 | case "pending": | |
| 318 | case "running": | |
| 319 | case "waiting": | |
| 320 | return "inProgress"; | |
| 321 | case "cancelled": | |
| 322 | case "interrupted": | |
| 323 | return "stopped"; | |
| 324 | case "idle": | |
| 325 | // A batch becomes idle when its parent turn ends. Other idle tasks can resume. | |
| 326 | return payload.taskType === "subagent_batch" ? "stopped" : undefined; | |
| 327 | case "inProgress": | |
| 328 | case "completed": | |
| 329 | case "failed": | |
| 330 | case "declined": | |
| 331 | case "stopped": | |
| 332 | return payload.status; | |
| 333 | default: | |
| 334 | return undefined; | |
| 335 | } | |
| 336 | } | |
| 337 | ||
| 338 | // Some providers report completion even when the output describes a failure. | |
| 339 | function toolDetailTextLooksLikeFailure(text: string): boolean { | |
| 340 | const normalized = text.toLowerCase(); | |
| 341 | return ( | |
| 342 | normalized.includes("file not found") || | |
| 343 | normalized.includes("no files found") || | |
| 344 | normalized.includes("enoent") || | |
| 345 | normalized.includes("no such file or directory") || | |
| 346 | normalized.includes("no such file") || | |
| 347 | normalized.includes("commandnotfoundexception") || | |
| 348 | normalized.includes("command not found") || | |
| 349 | (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || | |
| 350 | (normalized.includes("is not recognized") && normalized.includes("the term '")) || | |
| 351 | normalized.includes("is not recognized as the name of a cmdlet") || | |
| 352 | normalized.includes("a parameter cannot be found that matches parameter name") || | |
| 353 | /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) || | |
| 354 | /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || | |
| 355 | /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) | |
| 356 | ); | |
| 357 | } | |
| 358 | ||
| 359 | function workEntryIndicatesToolFailureFromOutput( | |
| 360 | entry: WorkLogPresentationEntry, | |
| 361 | includeCommand: boolean, | |
| 362 | ): boolean { | |
| 363 | if ( | |
| 364 | entry.tone === "error" || | |
| 365 | entry.toolLifecycleStatus === "failed" || | |
| 366 | entry.toolLifecycleStatus === "declined" | |
| 367 | ) { | |
| 368 | return true; | |
| 369 | } | |
| 370 | if (!workLogEntryIsToolLike(entry)) return false; | |
| 371 | const output = includeCommand | |
| 372 | ? [entry.detail, entry.command].filter(Boolean).join("\n") | |
| 373 | : (entry.detail ?? ""); | |
| 374 | return output.length > 0 && toolDetailTextLooksLikeFailure(output); | |
| 375 | } | |
| 376 | ||
| 377 | /** Includes legacy activities that stored error output in the command field. */ | |
| 378 | export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { | |
| 379 | return workEntryIndicatesToolFailureFromOutput(entry, true); | |
| 380 | } | |
| 381 | ||
| 382 | /** Checks rendered output without treating the user's command as an error. */ | |
| 383 | export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { | |
| 384 | return workEntryIndicatesToolFailureFromOutput(entry, false); | |
| 385 | } | |
| 386 | ||
| 387 | /** Decides whether the row can show a success marker. */ | |
| 388 | export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry): boolean { | |
| 389 | return ( | |
| 390 | workLogEntryIsToolLike(entry) && | |
| 391 | !workEntryIndicatesToolFailure(entry) && | |
| 392 | entry.tone !== "thinking" && | |
| 393 | entry.toolLifecycleStatus !== "inProgress" && | |
| 394 | entry.toolLifecycleStatus !== "stopped" | |
| 395 | ); | |
| 396 | } | |
| 397 | ||
| 308 | 398 | function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { |
| 309 | 399 | return ( |
| 310 | 400 | entry.itemType === "web_search" && |