fix(clients): reconcile tool activity lifecycles#7617
6 unresolved threads · Jump to file
- apps/web/src/components/chat/MessagesTimeline.test.tsx:1209–1211 · `activeTurnInProgress` was removed from `MessagesTimelineProps` in this PR, so this explicit JSX att
- apps/web/src/session-logic.ts:1209 · ### Id-less updates merge parallel tools **Medium Severity** <!-- DESCRIPTION START --> The new id
- apps/web/src/session-logic.ts:1173 · ### Late starts duplicate keyed tool rows **Medium Severity** <!-- DESCRIPTION START --> A delayed
- apps/web/src/session-logic.ts:1149 · ### Failed siblings block id-less collapse **Medium Severity** <!-- DESCRIPTION START --> `hasComp
- apps/web/src/session-logic.ts:1146–1146 · 🟡 **Medium** `src/session-logic.ts:1146` An earlier `approval.requested` row can make an id-less `
- apps/web/src/components/chat/MessagesTimeline.test.tsx:343–343 · This assertion doesn't match what the iconless Thinking row emits. `LiveActivityContent` builds `cn(
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);
+ }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),
+ );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
🟡 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.
| 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:
| 1 | In file @apps/web/src/session-logic.ts around line 1146: |
| 2 | |
| 3 | 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. |