fix: flush head before applying canonical catch-up entries on mobile

On mobile, the server drops live stream events for backgrounded or
unfocused agents.  When the user returns, a seq gap triggers a
catch-up fetch whose canonical entries are appended to the tail —
but the head was never flushed.  Stale live items from before the
gap stayed in the head and rendered after the newer catch-up entries,
breaking chronological order.  Worse, subsequent live events of the
same kind (e.g. assistant_message) would append to the stale head
item, garbling message content.

Now the incremental path in processTimelineResponse flushes head →
tail before reducing canonical entries, keeping the timeline ordered
and the head clean for new live events.
This commit is contained in:
Mohamed Boudra
2026-04-14 23:03:23 +07:00
parent 7f92f7f51d
commit 312a6e22fe
2 changed files with 20 additions and 3 deletions
@@ -1,7 +1,12 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
reduceStreamUpdate,
} from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -214,12 +219,24 @@ export function processTimelineResponse(
}
if (acceptedUnits.length > 0) {
// Flush head to tail before appending canonical entries so that
// chronological ordering is preserved. This matters on mobile where
// the server drops live events for backgrounded/unfocused agents:
// when the client catches up, the head may still hold stale live
// items from before the gap that must be committed ahead of the
// canonical gap-fill entries.
const baseTail =
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
if (currentHead.length > 0) {
nextHead = [];
}
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
baseTail,
);
}
+1 -1
View File
@@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
/**
* Flush head items to tail, avoiding duplicates.
*/
function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
if (head.length === 0) {
return tail;
}