fix(clients): reconcile tool activity lifecycles#7617

closed4
main
ui-refresh/tool-activity-turns
openedAug 20, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 4d ago
6 unresolved threads · Jump to file
Selected thread: apps/web/src/components/chat/MessagesTimeline.test.tsx:343 — This assertion doesn't match what the iconless Thinking row emits. `LiveActivityContent` builds `cn("flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", resolvedIconName ? "px-0.5" : "px-1", …)`, and with no icon (`ThinkingActivityRow`) the rendered order is `gap-1.5 py-0.5 px-1` — `px-0.5` is only emitted when an icon is present. `cn` (twMerge) does not reorder classes, so this test fails as written; the later "aligns the iconless Thinking row" test asserts the correct string. ```suggestion expect(markup).toContain("gap-1.5 py-0.5 px-1"); ``` <sup>Posted via Macroscope — <a href="https://github.com/pingdotgg/t3code/runs/96298292033">UI Consistency</a></sup>
0 / 10 viewed10 files changedLine totals unavailable

apps/web/src/session-logic.ts · Earlier revision 29acbf91 · right 1173

Original diff
@@ -1085,12 +1112,65 @@ function collapseDerivedWorkLogEntries(
       });
       continue;
     }
+    const lifecycleKey = toolLifecycleCollapseMapKey(entry);
+    if (lifecycleKey !== undefined) {
+      const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
+      if (matchingLifecycleIndex !== undefined) {
+        const matchingEntry = collapsed[matchingLifecycleIndex];
+        if (matchingEntry && workLogEntryHasTerminalToolLifecycle(matchingEntry)) {
+          collapsed[matchingLifecycleIndex] = workLogEntryHasTerminalToolLifecycle(entry)
+            ? mergeDerivedWorkLogEntries(matchingEntry, entry)
+            : mergeDerivedWorkLogEntries(entry, matchingEntry);
+          continue;
+        }
+        if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
+          const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
+          collapsed[matchingLifecycleIndex] = merged;
+          toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
+          continue;
+        }
+        toolLifecycleRowIndex.delete(lifecycleKey);
+      }
+    }
     const previous = collapsed.at(-1);
-    if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
-      collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
+    const hasCompetingIdlessCompletionTarget =
+      (previous?.activityKind === "tool.started" || previous?.activityKind === "tool.updated") &&
+      entry.activityKind === "tool.completed" &&
+      entry.toolCallId === undefined &&
+      collapsed
+        .slice(0, -1)
+        .some(
+          (candidate) =>
+            candidate.activityKind !== "tool.completed" &&
+            candidate.turnId === previous.turnId &&
+            candidate.itemType === previous.itemType &&
+            normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) ===
+              normalizeCompactToolLabel(previous.toolTitle ?? previous.label),
+        );
+    if (
+      previous &&
+      !hasCompetingIdlessCompletionTarget &&
+      shouldCollapseToolLifecycleEntries(previous, entry)
+    ) {
+      const previousIndex = collapsed.length - 1;
+      const previousKey = toolLifecycleCollapseMapKey(previous);
+      if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
+      const merged =
+        workLogEntryHasTerminalToolLifecycle(previous) &&
+        !workLogEntryHasTerminalToolLifecycle(entry)
+          ? mergeDerivedWorkLogEntries(entry, previous)
+          : mergeDerivedWorkLogEntries(previous, entry);
+      collapsed[previousIndex] = merged;
+      const mergedKey = toolLifecycleCollapseMapKey(merged);
+      if (mergedKey !== undefined) {
+        toolLifecycleRowIndex.set(mergedKey, previousIndex);
+      }
       continue;
     }
     collapsed.push(entry);
+    if (lifecycleKey !== undefined) {
+      toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
+    }
C
cursorBotAug 20, 2026

Late starts duplicate keyed tool rows

Medium Severity

A delayed tool.started for an already-tracked in-progress toolCallId is rejected by shouldCollapseToolLifecycleEntries, then toolLifecycleRowIndex.delete drops the map entry while the original row stays in collapsed. The start is pushed as a second row, so a later completion merges only into the new row and leaves a ghost in-progress tool in the work log.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 29acbf910281ef90d4349d61645b6818e2d78359. Configure here.

apps/web/src/session-logic.ts · Earlier revision 29acbf91 · right 1149

Original diff
@@ -1085,12 +1112,65 @@ function collapseDerivedWorkLogEntries(
       });
       continue;
     }
+    const lifecycleKey = toolLifecycleCollapseMapKey(entry);
+    if (lifecycleKey !== undefined) {
+      const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
+      if (matchingLifecycleIndex !== undefined) {
+        const matchingEntry = collapsed[matchingLifecycleIndex];
+        if (matchingEntry && workLogEntryHasTerminalToolLifecycle(matchingEntry)) {
+          collapsed[matchingLifecycleIndex] = workLogEntryHasTerminalToolLifecycle(entry)
+            ? mergeDerivedWorkLogEntries(matchingEntry, entry)
+            : mergeDerivedWorkLogEntries(entry, matchingEntry);
+          continue;
+        }
+        if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
+          const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
+          collapsed[matchingLifecycleIndex] = merged;
+          toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
+          continue;
+        }
+        toolLifecycleRowIndex.delete(lifecycleKey);
+      }
+    }
     const previous = collapsed.at(-1);
-    if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
-      collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
+    const hasCompetingIdlessCompletionTarget =
+      (previous?.activityKind === "tool.started" || previous?.activityKind === "tool.updated") &&
+      entry.activityKind === "tool.completed" &&
+      entry.toolCallId === undefined &&
+      collapsed
+        .slice(0, -1)
+        .some(
+          (candidate) =>
+            candidate.activityKind !== "tool.completed" &&
+            candidate.turnId === previous.turnId &&
+            candidate.itemType === previous.itemType &&
+            normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) ===
+              normalizeCompactToolLabel(previous.toolTitle ?? previous.label),
+        );
C
cursorBotAug 20, 2026

Failed siblings block id-less collapse

Medium Severity

hasCompetingIdlessCompletionTarget treats any earlier non-tool.completed row with the same turn, item type, and label as a competitor, including rows that are already terminal via failed, declined, or stopped. An unambiguous id-less completion then stays unmerged, so the remaining running call never settles.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 29acbf910281ef90d4349d61645b6818e2d78359. Configure here.

apps/web/src/session-logic.ts · Earlier revision 29acbf91 · right 1146–1146

Original diff
@@ -1085,12 +1112,65 @@ function collapseDerivedWorkLogEntries(
       });
       continue;
     }
+    const lifecycleKey = toolLifecycleCollapseMapKey(entry);
+    if (lifecycleKey !== undefined) {
+      const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
+      if (matchingLifecycleIndex !== undefined) {
+        const matchingEntry = collapsed[matchingLifecycleIndex];
+        if (matchingEntry && workLogEntryHasTerminalToolLifecycle(matchingEntry)) {
+          collapsed[matchingLifecycleIndex] = workLogEntryHasTerminalToolLifecycle(entry)
+            ? mergeDerivedWorkLogEntries(matchingEntry, entry)
+            : mergeDerivedWorkLogEntries(entry, matchingEntry);
+          continue;
+        }
+        if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
+          const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
+          collapsed[matchingLifecycleIndex] = merged;
+          toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
+          continue;
+        }
+        toolLifecycleRowIndex.delete(lifecycleKey);
+      }
+    }
     const previous = collapsed.at(-1);
-    if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
-      collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
+    const hasCompetingIdlessCompletionTarget =
+      (previous?.activityKind === "tool.started" || previous?.activityKind === "tool.updated") &&
+      entry.activityKind === "tool.completed" &&
+      entry.toolCallId === undefined &&
+      collapsed
+        .slice(0, -1)
+        .some(
+          (candidate) =>
+            candidate.activityKind !== "tool.completed" &&
+            candidate.turnId === previous.turnId &&
+            candidate.itemType === previous.itemType &&

apps/web/src/session-logic.ts · right 1146–1146

M
macroscopeappBotAug 20, 2026

🟡 Medium src/session-logic.ts:1146

An earlier approval.requested row can make an id-less tool.completed appear ambiguous, so the keyed tool.started remains visible as running and a duplicate completion row is emitted. The candidate check must consider only tool.started and tool.updated rows as competing lifecycle targets.

diff
1- candidate.activityKind !== "tool.completed" &&
2+ (candidate.activityKind === "tool.started" ||
3+ candidate.activityKind === "tool.updated") &&
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/session-logic.ts around line 1146:
2
3An earlier `approval.requested` row can make an id-less `tool.completed` appear ambiguous, so the keyed `tool.started` remains visible as running and a duplicate completion row is emitted. The candidate check must consider only `tool.started` and `tool.updated` rows as competing lifecycle targets.