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/session-logic.ts:1173 — ### Late starts duplicate keyed tool rows **Medium Severity** <!-- DESCRIPTION START --> 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. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: 49acf7b4-c148-42a9-91a8-35cfcb20838d --> <!-- LOCATIONS START apps/web/src/session-logic.ts#L1114-L1173 apps/web/src/session-logic.ts#L1198-L1204 LOCATIONS END --> <details> <summary>Additional Locations (1)</summary> - [`apps/web/src/session-logic.ts#L1198-L1204`](https://github.com/pingdotgg/t3code/blob/29acbf910281ef90d4349d61645b6818e2d78359/apps/web/src/session-logic.ts#L1198-L1204) </details> <div><a href="https://cursor.com/open?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmQwMDZkOGRjLWRiNmYtNDFlZS1iY2MxLTcwMmYxNzhkYzQ1ZiIsImVuY3J5cHRpb25LZXkiOiJYZDctaXMtR0RQZmNGVVMyRjNkVjcxakJzb0ZQYXhBZ1lEMC1NSFNJRHdzIiwiYnJhbmNoIjoidWktcmVmcmVzaC90b29sLWFjdGl2aXR5LXR1cm5zIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwcm92aWRlciI6ImdpdGh1YiJ9fQ" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-cursor-light.png"><img alt="Fix in Cursor" width="115" height="28" src="https://cursor.com/assets/images/fix-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmQwMDZkOGRjLWRiNmYtNDFlZS1iY2MxLTcwMmYxNzhkYzQ1ZiIsImVuY3J5cHRpb25LZXkiOiJYZDctaXMtR0RQZmNGVVMyRjNkVjcxakJzb0ZQYXhBZ1lEMC1NSFNJRHdzIiwiYnJhbmNoIjoidWktcmVmcmVzaC90b29sLWFjdGl2aXR5LXR1cm5zIiwicmVwb093bmVyIjoicGluZ2RvdGdnIiwicmVwb05hbWUiOiJ0M2NvZGUiLCJwck51bWJlciI6NzYxNywiY29tbWl0U2hhIjoiMjlhY2JmOTEwMjgxZWY5MGQ0MzQ5ZDYxNjQ1YjY4MThlMmQ3ODM1OSIsInByb3ZpZGVyIjoiZ2l0aHViIn19" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-web-light.png"><img alt="Fix in Web" width="99" height="28" src="https://cursor.com/assets/images/fix-in-web-dark.png"></picture></a></div> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 29acbf910281ef90d4349d61645b6818e2d78359. Configure [here](https://www.cursor.com/dashboard/bugbot).</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.