summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/reducer.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks/reducer.ts')
-rw-r--r--src/core/chunks/reducer.ts39
1 files changed, 34 insertions, 5 deletions
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index ee5858f..35bb1bb 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -77,6 +77,26 @@ function tagTrailingUserEcho(
}
/**
+ * Drop the previous copy of a turn that is being replayed from its start.
+ * Keeps the trailing user run — the opening this replay's `user-message` just
+ * appended (or the sender's echo that `turn-start` just tagged) — and every
+ * other turn's chunks.
+ */
+function dropReplayedTurn(
+ provisional: readonly ProvisionalChunk[],
+ turnId: string,
+): readonly ProvisionalChunk[] {
+ let tail = provisional.length;
+ while (tail > 0 && provisional[tail - 1]?.role === "user") tail--;
+ // A stale steering bubble (replayed again as its own `steering` event) can
+ // end the old copy and so sit inside the trailing user run.
+ const stale = (p: ProvisionalChunk, i: number): boolean =>
+ p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined);
+ if (!provisional.some(stale)) return provisional;
+ return provisional.filter((p, i) => !stale(p, i));
+}
+
+/**
* Content equality for two chunks of the SAME role (used to de-dup an
* optimistic echo against the authoritative committed version). Compares the
* discriminating payload only — `stepId` (generation provenance) is ignored
@@ -288,17 +308,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "tool-output":
return state;
- case "turn-start":
+ case "turn-start": {
+ // A turn starts exactly once live, so a `turn-start` for a turn that
+ // already has provisional content is a late-join REPLAY restarting from
+ // the top (re-subscribe after a tab switch / WS reconnect): the server
+ // re-delivers the whole in-flight turn. Replace the stale copy instead of
+ // appending a second one (a duplicate copy rendered as a tail of thinking
+ // blocks with its tool cards collapsed onto the first copy's).
+ const stale = state.accumulating?.turnId === event.turnId;
+ const provisional = tagTrailingUserEcho(
+ flushAccumulating(state.provisional, stale ? null : state.accumulating),
+ event.turnId,
+ );
return {
...state,
- provisional: tagTrailingUserEcho(
- flushAccumulating(state.provisional, state.accumulating),
- event.turnId,
- ),
+ provisional: dropReplayedTurn(provisional, event.turnId),
accumulating: null,
currentTurnId: event.turnId,
generating: true,
};
+ }
case "user-message": {
// The turn's USER prompt, surfaced on the event stream (backend CR-3) so a