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";...@@ -16,6 +16,7 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming";
16import {16import {
17 commandDetailRepeatsCommand,17 commandDetailRepeatsCommand,
18 extractCommandOutputText,18 extractCommandOutputText,
19 extractWorkLogToolLifecycleStatus,
19 isWorktreeSetupActivity,20 isWorktreeSetupActivity,
20 liveActivityToolStatus,21 liveActivityToolStatus,
21 normalizeCompactToolLabel,22 normalizeCompactToolLabel,
...@@ -24,7 +25,11 @@ import {...@@ -24,7 +25,11 @@ import {
24 summarizeToolGroup,25 summarizeToolGroup,
25 toolGroupAction,26 toolGroupAction,
26 toolGroupSummaryKind,27 toolGroupSummaryKind,
28 workEntryIndicatesToolFailure,
29 workEntryIndicatesToolSuccess,
30 workLogEntryIsToolLike,
27 type ToolGroupSummaryKind,31 type ToolGroupSummaryKind,
32 type WorkLogToolLifecycleStatus,
28} from "@t3tools/client-runtime/work-log/presentation";33} from "@t3tools/client-runtime/work-log/presentation";
29import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";34import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
30import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";35import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
...@@ -88,8 +93,6 @@ export interface ThreadFeedActivity {...@@ -88,8 +93,6 @@ export interface ThreadFeedActivity {
88 readonly live?: boolean;93 readonly live?: boolean;
89}94}
9095
91type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
92
93export interface WorkLogEntry {96export interface WorkLogEntry {
94 id: string;97 id: string;
95 createdAt: string;98 createdAt: string;
...@@ -989,67 +992,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un...@@ -989,67 +992,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
989 return [itemType, normalizedLabel, detail].join("\u001f");992 return [itemType, normalizedLabel, detail].join("\u001f");
990}993}
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
1053function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {995function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
1054 if (entry.agentSpawn) {996 if (entry.agentSpawn) {
1055 switch (entry.toolLifecycleStatus) {997 switch (entry.toolLifecycleStatus) {
...@@ -1521,27 +1463,6 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul...@@ -1521,27 +1463,6 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
1521 return asTrimmedString(payload?.title);1463 return asTrimmedString(payload?.title);
1522}1464}
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
1545function stripTrailingExitCode(value: string): {1466function stripTrailingExitCode(value: string): {
1546 output: string | null;1467 output: string | null;
1547 exitCode?: number | undefined;1468 exitCode?: number | undefined;
apps/web/src/session-logic.test.ts+30-110
...@@ -23,9 +23,7 @@ import {...@@ -23,9 +23,7 @@ import {
23 isLatestTurnSettled,23 isLatestTurnSettled,
24 selectHandoffImageResources,24 selectHandoffImageResources,
25 selectMessageImageResources,25 selectMessageImageResources,
26 workEntryIndicatesToolFailure,
27 workEntryIndicatesToolNeutralStatus,26 workEntryIndicatesToolNeutralStatus,
28 workEntryIndicatesToolSuccess,
29} from "./session-logic";27} from "./session-logic";
3028
31let nextActivityId = 0;29let nextActivityId = 0;
...@@ -809,121 +807,43 @@ describe("hasActionableProposedPlan", () => {...@@ -809,121 +807,43 @@ describe("hasActionableProposedPlan", () => {
809 });807 });
810});808});
811809
812describe("workEntryIndicatesToolFailure", () => {810describe("workEntryIndicatesToolNeutralStatus", () => {
813 const base = {811 it("keeps active tools neutral and agent spawns visible", () => {
814 id: "w1",812 const entry = {
815 createdAt: "2026-01-01T00:00:00.000Z",813 id: "work-1",
816 label: "Read",814 createdAt: "2026-01-01T00:00:00.000Z",
817 };815 label: "Read",
818816 tone: "tool" as const,
819 it("is true for error tone", () => {817 toolLifecycleStatus: "inProgress" as const,
820 expect(818 };
821 workEntryIndicatesToolFailure({819 expect(workEntryIndicatesToolNeutralStatus(entry)).toBe(true);
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);
907 expect(820 expect(
908 workEntryIndicatesToolNeutralStatus({821 workEntryIndicatesToolNeutralStatus({
909 ...base,822 ...entry,
910 tone: "tool",823 agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] },
911 toolLifecycleStatus: "completed",
912 detail: "ok",
913 }),824 }),
914 ).toBe(false);825 ).toBe(false);
915 });
916
917 it("does not run heuristics on non-tool info rows", () => {
918 expect(826 expect(
919 workEntryIndicatesToolFailure({827 workEntryIndicatesToolNeutralStatus({ ...entry, toolLifecycleStatus: "completed" }),
920 ...base,
921 label: "Context compacted",
922 tone: "info",
923 detail: "File not found in conversation",
924 }),
925 ).toBe(false);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});
928848
929describe("deriveWorkLogEntries", () => {849describe("deriveWorkLogEntries", () => {
apps/web/src/session-logic.ts+11-146
...@@ -6,7 +6,12 @@ import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagent...@@ -6,7 +6,12 @@ import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagent
6import {6import {
7 commandDetailRepeatsCommand,7 commandDetailRepeatsCommand,
8 extractCommandOutputText,8 extractCommandOutputText,
9 extractWorkLogToolLifecycleStatus,
9 isWorktreeSetupActivity,10 isWorktreeSetupActivity,
11 workEntryIndicatesToolFailure,
12 workEntryIndicatesToolSuccess,
13 workLogEntryIsToolLike,
14 type WorkLogToolLifecycleStatus,
10} from "@t3tools/client-runtime/work-log/presentation";15} from "@t3tools/client-runtime/work-log/presentation";
11import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";16import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
12import {17import {
...@@ -37,12 +42,12 @@ import {...@@ -37,12 +42,12 @@ import {
3742
38export { formatDuration } from "@t3tools/shared/orchestrationTiming";43export { formatDuration } from "@t3tools/shared/orchestrationTiming";
3944
40export type WorkLogToolLifecycleStatus =45export {
41 | "inProgress"46 workEntryDisplayIndicatesToolFailure,
42 | "completed"47 workEntryIndicatesToolSuccess,
43 | "failed"48 workLogEntryIsToolLike,
44 | "declined"49 type WorkLogToolLifecycleStatus,
45 | "stopped";50} from "@t3tools/client-runtime/work-log/presentation";
4651
47export interface WorkLogEntry {52export interface WorkLogEntry {
48 id: string;53 id: string;
...@@ -167,103 +172,6 @@ export interface TimelineEntriesProjection {...@@ -167,103 +172,6 @@ export interface TimelineEntriesProjection {
167 readonly entries: TimelineEntry[];172 readonly entries: TimelineEntry[];
168}173}
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
267/** Severe failures keep the red treatment ordinary tool failures lost: runtime175/** Severe failures keep the red treatment ordinary tool failures lost: runtime
268 * errors and orchestration `*.failed` activities (provider.turn.start.failed,176 * errors and orchestration `*.failed` activities (provider.turn.start.failed,
269 * checkpoint.capture.failed, ...) mean the turn or a core side effect broke,177 * checkpoint.capture.failed, ...) mean the turn or a core side effect broke,
...@@ -275,30 +183,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {...@@ -275,30 +183,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
275 );183 );
276}184}
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
302/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */186/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
303export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {187export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
304 // Spawn CTA rows are never neutral-hidden: mid-run they derive from188 // Spawn CTA rows are never neutral-hidden: mid-run they derive from
...@@ -827,25 +711,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -827,25 +711,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
827 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");711 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
828}712}
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
849function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {714function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
850 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);715 const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
851 if (cachedEntry) {716 if (cachedEntry) {
packages/client-runtime/src/work-log/presentation.test.ts+122
...@@ -12,8 +12,130 @@ import {...@@ -12,8 +12,130 @@ import {
12 toolGroupSummaryKind,12 toolGroupSummaryKind,
13 type WorkLogPresentationEntry,13 type WorkLogPresentationEntry,
14 workEntryViewedImagePath,14 workEntryViewedImagePath,
15 workEntryIndicatesToolFailure,
16 workEntryDisplayIndicatesToolFailure,
17 workEntryIndicatesToolSuccess,
15} from "./presentation.js";18} 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
17describe("summarizeToolGroup", () => {139describe("summarizeToolGroup", () => {
18 it.each(["command", "file-read", "file-change"])(140 it.each(["command", "file-read", "file-change"])(
19 "keeps %s approvals out of tool execution counts",141 "keeps %s approvals out of tool execution counts",
packages/client-runtime/src/work-log/presentation.ts+91-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 isToolLifecycleItemType,2 isToolLifecycleItemType,
3 type AssetResource,3 type AssetResource,
4 type RuntimeItemStatus,
4 type ThreadId,5 type ThreadId,
5 type ToolActivitySource,6 type ToolActivitySource,
6 type ToolLifecycleItemType,7 type ToolLifecycleItemType,
...@@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean {...@@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean {
13 return kind === "setup-script.requested" || kind === "setup-script.started";14 return kind === "setup-script.requested" || kind === "setup-script.started";
14}15}
1516
17export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped";
18
16export interface WorkLogPresentationEntry {19export interface WorkLogPresentationEntry {
17 readonly label: string;20 readonly label: string;
18 readonly toolTitle?: string;21 readonly toolTitle?: string;
...@@ -298,13 +301,100 @@ export function commandDetailRepeatsCommand(input: {...@@ -298,13 +301,100 @@ export function commandDetailRepeatsCommand(input: {
298 );301 );
299}302}
300303
301function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {304export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
302 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;305 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
303 if (entry.command !== undefined && entry.command.trim().length > 0) return true;306 if (entry.command !== undefined && entry.command.trim().length > 0) return true;
304 if (entry.requestKind !== undefined) return true;307 if (entry.requestKind !== undefined) return true;
305 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);308 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
306}309}
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
308function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {398function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean {
309 return (399 return (
310 entry.itemType === "web_search" &&400 entry.itemType === "web_search" &&