authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:15:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:15:19-07:00
logac11bd29b03ec568f2616c78d885574eff16ad3c
tree98f96ac22b0e8de67c4ed6be8566e4c72185bdbd
parentdd64072917193195479f6907cfb081267bff7301
signature Signed by PGP key B5690EEEBB952194

refactor(client): share tool outcome rules (#10122)


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";
1616import {
1717 commandDetailRepeatsCommand,
1818 extractCommandOutputText,
19 extractWorkLogToolLifecycleStatus,
1920 isWorktreeSetupActivity,
2021 liveActivityToolStatus,
2122 normalizeCompactToolLabel,
......@@ -24,7 +25,11 @@ import {
2425 summarizeToolGroup,
2526 toolGroupAction,
2627 toolGroupSummaryKind,
28 workEntryIndicatesToolFailure,
29 workEntryIndicatesToolSuccess,
30 workLogEntryIsToolLike,
2731 type ToolGroupSummaryKind,
32 type WorkLogToolLifecycleStatus,
2833} from "@t3tools/client-runtime/work-log/presentation";
2934import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
3035import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
......@@ -88,8 +93,6 @@ export interface ThreadFeedActivity {
8893 readonly live?: boolean;
8994}
9095
91type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
92
9396export interface WorkLogEntry {
9497 id: string;
9598 createdAt: string;
......@@ -989,67 +992,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
989992 return [itemType, normalizedLabel, detail].join("\u001f");
990993}
991994
992function 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
1005function 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
1025function 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
1038function 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
1053995function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
1054996 if (entry.agentSpawn) {
1055997 switch (entry.toolLifecycleStatus) {
......@@ -1521,27 +1463,6 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
15211463 return asTrimmedString(payload?.title);
15221464}
15231465
1524function 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
15451466function stripTrailingExitCode(value: string): {
15461467 output: string | null;
15471468 exitCode?: number | undefined;
apps/web/src/session-logic.test.ts+30-110
......@@ -23,9 +23,7 @@ import {
2323 isLatestTurnSettled,
2424 selectHandoffImageResources,
2525 selectMessageImageResources,
26 workEntryIndicatesToolFailure,
2726 workEntryIndicatesToolNeutralStatus,
28 workEntryIndicatesToolSuccess,
2927} from "./session-logic";
3028
3129let nextActivityId = 0;
......@@ -809,121 +807,43 @@ describe("hasActionableProposedPlan", () => {
809807 });
810808});
811809
812describe("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);
810describe("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);
907820 expect(
908821 workEntryIndicatesToolNeutralStatus({
909 ...base,
910 tone: "tool",
911 toolLifecycleStatus: "completed",
912 detail: "ok",
822 ...entry,
823 agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] },
913824 }),
914825 ).toBe(false);
915 });
916
917 it("does not run heuristics on non-tool info rows", () => {
918826 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" }),
925828 ).toBe(false);
926829 });
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 );
927847});
928848
929849describe("deriveWorkLogEntries", () => {
apps/web/src/session-logic.ts+11-146
......@@ -6,7 +6,12 @@ import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagent
66import {
77 commandDetailRepeatsCommand,
88 extractCommandOutputText,
9 extractWorkLogToolLifecycleStatus,
910 isWorktreeSetupActivity,
11 workEntryIndicatesToolFailure,
12 workEntryIndicatesToolSuccess,
13 workLogEntryIsToolLike,
14 type WorkLogToolLifecycleStatus,
1015} from "@t3tools/client-runtime/work-log/presentation";
1116import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1217import {
......@@ -37,12 +42,12 @@ import {
3742
3843export { formatDuration } from "@t3tools/shared/orchestrationTiming";
3944
40export type WorkLogToolLifecycleStatus =
41 | "inProgress"
42 | "completed"
43 | "failed"
44 | "declined"
45 | "stopped";
45export {
46 workEntryDisplayIndicatesToolFailure,
47 workEntryIndicatesToolSuccess,
48 workLogEntryIsToolLike,
49 type WorkLogToolLifecycleStatus,
50} from "@t3tools/client-runtime/work-log/presentation";
4651
4752export interface WorkLogEntry {
4853 id: string;
......@@ -167,103 +172,6 @@ export interface TimelineEntriesProjection {
167172 readonly entries: TimelineEntry[];
168173}
169174
170export 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`. */
184function 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
229function 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`. */
258export 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. */
263export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean {
264 return workEntryIndicatesToolFailureFromOutput(entry, false);
265}
266
267175/** Severe failures keep the red treatment ordinary tool failures lost: runtime
268176 * errors and orchestration `*.failed` activities (provider.turn.start.failed,
269177 * checkpoint.capture.failed, ...) mean the turn or a core side effect broke,
......@@ -275,30 +183,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
275183 );
276184}
277185
278/** Tool/command row completed without failure (blue check affordance). */
279export 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
302186/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
303187export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
304188 // Spawn CTA rows are never neutral-hidden: mid-run they derive from
......@@ -827,25 +711,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
827711 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
828712}
829713
830function 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
849714function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
850715 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
851716 if (cachedEntry) {
packages/client-runtime/src/work-log/presentation.test.ts+122
......@@ -12,8 +12,130 @@ import {
1212 toolGroupSummaryKind,
1313 type WorkLogPresentationEntry,
1414 workEntryViewedImagePath,
15 workEntryIndicatesToolFailure,
16 workEntryDisplayIndicatesToolFailure,
17 workEntryIndicatesToolSuccess,
1518} from "./presentation.js";
1619
20describe("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
17139describe("summarizeToolGroup", () => {
18140 it.each(["command", "file-read", "file-change"])(
19141 "keeps %s approvals out of tool execution counts",
packages/client-runtime/src/work-log/presentation.ts+91-1
......@@ -1,6 +1,7 @@
11import {
22 isToolLifecycleItemType,
33 type AssetResource,
4 type RuntimeItemStatus,
45 type ThreadId,
56 type ToolActivitySource,
67 type ToolLifecycleItemType,
......@@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean {
1314 return kind === "setup-script.requested" || kind === "setup-script.started";
1415}
1516
17export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped";
18
1619export interface WorkLogPresentationEntry {
1720 readonly label: string;
1821 readonly toolTitle?: string;
......@@ -298,13 +301,100 @@ export function commandDetailRepeatsCommand(input: {
298301 );
299302}
300303
301function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
304export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
302305 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
303306 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
304307 if (entry.requestKind !== undefined) return true;
305308 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
306309}
307310
311/** Maps item and task status to the status shown on a work-log row. */
312export 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.
339function 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
359function 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. */
378export 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. */
383export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean {
384 return workEntryIndicatesToolFailureFromOutput(entry, false);
385}
386
387/** Decides whether the row can show a success marker. */
388export 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
308398function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
309399 return (
310400 entry.itemType === "web_search" &&