Fix duplicated and out-of-order agent chat messages (#2185)

* fix(app): keep agent timelines ordered during catch-up

Projected catch-up pages can overlap live rows and arrive after optimistic
prompts. Reconcile canonical projections before the existing stream reducer,
and retain selective subscriptions briefly across quick view switches.

* refactor(app): make optimistic catch-up consumption explicit

* fix(app): preserve catch-up message order

* fix(app): isolate delayed catch-up history

* fix(app): anchor catch-up before live turns

* fix(app): stage complete catch-up history

* fix(app): keep timeline arrays Hermes-safe

* refactor(app): preserve prompt positions during catch-up
This commit is contained in:
Mohamed Boudra
2026-07-18 13:49:32 +02:00
committed by GitHub
parent 99da5736db
commit 98f6611362
6 changed files with 1325 additions and 67 deletions
@@ -2,9 +2,10 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem, UserMessageItem } from "@/types/stream";
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
isAgentToolCallItem,
mergeAgentToolCallItem,
@@ -544,6 +545,378 @@ function replaceLiveAssistantWithProjectedText(params: {
return next;
}
interface OptimisticUserMessagePosition {
item: UserMessageItem;
placement: "tail" | "head";
index: number;
}
function findOptimisticUserMessage(params: {
tail: StreamItem[];
head: StreamItem[];
}): OptimisticUserMessagePosition | null {
const tailIndex = params.tail.findIndex(
(item) => item.kind === "user_message" && item.optimistic,
);
const tailItem = params.tail[tailIndex];
if (tailItem?.kind === "user_message") {
return { item: tailItem, placement: "tail", index: tailIndex };
}
const headIndex = params.head.findIndex(
(item) => item.kind === "user_message" && item.optimistic,
);
const headItem = params.head[headIndex];
return headItem?.kind === "user_message"
? { item: headItem, placement: "head", index: headIndex }
: null;
}
function replaceOptimisticUserMessage(params: {
tail: StreamItem[];
head: StreamItem[];
position: OptimisticUserMessagePosition;
precedingItems: StreamItem[];
replacement: StreamItem[];
}): { tail: StreamItem[]; head: StreamItem[] } {
const { position } = params;
const items = [...params.precedingItems, ...params.replacement];
if (position.placement === "tail") {
return {
tail: [
...params.tail.slice(0, position.index),
...items,
...params.tail.slice(position.index + 1),
],
head: params.head,
};
}
return {
tail: params.tail,
head: [
...params.head.slice(0, position.index),
...items,
...params.head.slice(position.index + 1),
],
};
}
function reconcileOverlappingProjectedAssistant(params: {
tail: StreamItem[];
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
currentEndSeq: number;
}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } {
const { unit } = params;
if (
unit.event.type !== "timeline" ||
unit.event.item.type !== "assistant_message" ||
!unit.sourceSeqRanges.some(
(range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq,
)
) {
return { tail: params.tail, head: params.head, reconciled: false };
}
const projectedText = unit.event.item.text;
const projectedMessageId = unit.event.item.messageId;
const matches = (item: StreamItem) => {
if (item.kind !== "assistant_message") return false;
if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId;
return projectedText.startsWith(item.text);
};
const findMatch = (items: StreamItem[]) => {
const index = items.findLastIndex(matches);
const current = items[index];
return current?.kind === "assistant_message" ? { current, index } : null;
};
const headMatch = findMatch(params.head);
const tailMatch = headMatch ? null : findMatch(params.tail);
const match = headMatch ?? tailMatch;
if (!match) {
return { tail: params.tail, head: params.head, reconciled: false };
}
const blockGroupId = match.current.blockGroupId;
const messageId = projectedMessageId ?? match.current.messageId;
const replacement: AssistantMessageItem = {
kind: "assistant_message",
id: blockGroupId ?? match.current.id,
...(messageId !== undefined ? { messageId } : {}),
text: projectedText,
timestamp: unit.timestamp,
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
};
const belongsToBlockGroup = (item: StreamItem) =>
blockGroupId !== undefined &&
item.kind === "assistant_message" &&
item.blockGroupId === blockGroupId;
const removeBlockGroup = (items: StreamItem[]) =>
blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items;
const replaceMatch = (items: StreamItem[], index: number) => {
if (!blockGroupId) {
const next = [...items];
next[index] = replacement;
return next;
}
const next: StreamItem[] = [];
let inserted = false;
for (const item of items) {
if (!belongsToBlockGroup(item)) {
next.push(item);
} else if (!inserted) {
next.push(replacement);
inserted = true;
}
}
return next;
};
if (headMatch) {
return {
tail: removeBlockGroup(params.tail),
head: replaceMatch(params.head, headMatch.index),
reconciled: true,
};
}
return {
tail: replaceMatch(params.tail, match.index),
head: removeBlockGroup(params.head),
reconciled: true,
};
}
function reconcileOverlappingProjectedReasoning(params: {
tail: StreamItem[];
head: StreamItem[];
unit: TimelineUnit;
currentEndSeq: number;
}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } {
const { unit } = params;
if (
unit.event.type !== "timeline" ||
unit.event.item.type !== "reasoning" ||
!unit.sourceSeqRanges.some(
(range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq,
)
) {
return { tail: params.tail, head: params.head, reconciled: false };
}
const projectedText = unit.event.item.text;
const replaceIn = (items: StreamItem[]): StreamItem[] | null => {
const index = items.findLastIndex(
(item) => item.kind === "thought" && projectedText.startsWith(item.text),
);
const current = items[index];
if (!current || current.kind !== "thought") return null;
const next = [...items];
next[index] = {
...current,
text: projectedText,
timestamp: unit.timestamp,
status: "loading",
};
return next;
};
const nextHead = replaceIn(params.head);
if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true };
const nextTail = replaceIn(params.tail);
return nextTail
? { tail: nextTail, head: params.head, reconciled: true }
: { tail: params.tail, head: params.head, reconciled: false };
}
function reconcileOverlappingProjectedStreamItems(params: {
tail: StreamItem[];
head: StreamItem[];
units: TimelineUnit[];
epoch: string;
currentEndSeq: number | undefined;
}): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set<TimelineUnit> } {
let tail = params.tail;
let head = params.head;
const reconciledUnits = new Set<TimelineUnit>();
if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits };
for (const unit of params.units) {
let reconciled = reconcileOverlappingProjectedAssistant({
tail,
head,
unit,
epoch: params.epoch,
currentEndSeq: params.currentEndSeq,
});
if (!reconciled.reconciled) {
reconciled = reconcileOverlappingProjectedReasoning({
tail,
head,
unit,
currentEndSeq: params.currentEndSeq,
});
}
tail = reconciled.tail;
head = reconciled.head;
if (reconciled.reconciled) reconciledUnits.add(unit);
}
return { tail, head, reconciledUnits };
}
function matchesOptimisticUserMessage(params: {
unit: TimelineUnit;
optimistic: UserMessageItem;
}): boolean {
const { event } = params.unit;
if (event.type !== "timeline" || event.item.type !== "user_message") {
return false;
}
if (event.item.messageId !== undefined) {
return event.item.messageId === params.optimistic.id;
}
return event.item.text.length > 0 && event.item.text === params.optimistic.text;
}
function acknowledgeOptimisticUserMessage(params: {
tail: StreamItem[];
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
optimistic: OptimisticUserMessagePosition;
precedingItems: StreamItem[];
}): { tail: StreamItem[]; head: StreamItem[] } {
const { event, timestamp, seqEnd } = params.unit;
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
const acknowledged = reduceStreamUpdate([params.optimistic.item], event, timestamp, {
source: "canonical",
timelineCursor,
});
return replaceOptimisticUserMessage({
tail: params.tail,
head: params.head,
position: params.optimistic,
precedingItems: params.precedingItems,
replacement: acknowledged,
});
}
function applyCanonicalForwardUnit(params: {
tail: StreamItem[];
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
}): { tail: StreamItem[]; head: StreamItem[] } {
const { event, timestamp, seqEnd } = params.unit;
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
if (params.head.length === 0) {
return {
tail: reduceStreamUpdate(params.tail, event, timestamp, {
source: "canonical",
timelineCursor,
}),
head: params.head,
};
}
const replacedHead = replaceLiveAssistantWithProjectedText({
head: params.head,
event,
timestamp,
timelineCursor,
});
if (replacedHead) return { tail: params.tail, head: replacedHead };
const applied = applyStreamEvent({
tail: params.tail,
head: params.head,
event,
timestamp,
source: "canonical",
timelineCursor,
});
return { tail: applied.tail, head: applied.head };
}
function applyAcceptedForwardTimelineUnits(params: {
units: TimelineUnit[];
epoch: string;
currentTail: StreamItem[];
currentHead: StreamItem[];
currentEndSeq: number | undefined;
}): { tail: StreamItem[]; head: StreamItem[] } {
const reconciled = reconcileOverlappingProjectedStreamItems({
tail: params.currentTail,
head: params.currentHead,
units: params.units,
epoch: params.epoch,
currentEndSeq: params.currentEndSeq,
});
let tail = reconciled.tail;
let head = reconciled.head;
let delayedHistoryTail: StreamItem[] = [];
let delayedHistoryHead: StreamItem[] = [];
for (const unit of params.units) {
if (reconciled.reconciledUnits.has(unit)) continue;
const nextOptimistic = findOptimisticUserMessage({ tail, head });
if (nextOptimistic && matchesOptimisticUserMessage({ unit, optimistic: nextOptimistic.item })) {
const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead);
delayedHistoryTail = [];
delayedHistoryHead = [];
const applied = acknowledgeOptimisticUserMessage({
tail,
head,
unit,
epoch: params.epoch,
optimistic: nextOptimistic,
precedingItems,
});
tail = applied.tail;
head = applied.head;
continue;
}
if (nextOptimistic) {
if (
unit.event.type === "timeline" &&
(unit.event.item.type === "assistant_message" || unit.event.item.type === "reasoning")
) {
delayedHistoryHead = reduceStreamUpdate(delayedHistoryHead, unit.event, unit.timestamp, {
source: "canonical",
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
});
continue;
}
const applied = applyStreamEvent({
tail: delayedHistoryTail,
head: delayedHistoryHead,
event: unit.event,
timestamp: unit.timestamp,
source: "canonical",
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
});
delayedHistoryTail = applied.tail;
delayedHistoryHead = applied.head;
continue;
}
const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch });
tail = applied.tail;
head = applied.head;
}
const remainingOptimistic = findOptimisticUserMessage({ tail, head });
if (!remainingOptimistic) return { tail, head };
const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead);
if (precedingItems.length === 0) return { tail, head };
return replaceOptimisticUserMessage({
tail,
head,
position: remainingOptimistic,
precedingItems,
replacement: [remainingOptimistic.item],
});
}
function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -586,39 +959,16 @@ function applyTimelineIncrementalPath(args: {
{ source: "canonical" },
);
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else if (currentHead.length > 0) {
for (const { event, timestamp, seqEnd } of acceptedUnits) {
const timelineCursor = { epoch: payload.epoch, seq: seqEnd };
const replacedHead = replaceLiveAssistantWithProjectedText({
head: nextHead,
event,
timestamp,
timelineCursor,
});
if (replacedHead) {
nextHead = replacedHead;
continue;
}
const applied = applyStreamEvent({
tail: nextTail,
head: nextHead,
event,
timestamp,
source: "canonical",
timelineCursor,
});
nextTail = applied.tail;
nextHead = applied.head;
}
} else {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp, seqEnd }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
}),
currentTail,
);
const applied = applyAcceptedForwardTimelineUnits({
units: acceptedUnits,
epoch: payload.epoch,
currentTail: nextTail,
currentHead: nextHead,
currentEndSeq: currentCursor?.endSeq,
});
nextTail = applied.tail;
nextHead = applied.head;
}
}