Preserve timeline rows across delayed hydration

This commit is contained in:
Mohamed Boudra
2026-07-28 00:32:51 +02:00
parent f46d75494b
commit 1701e68ea1
3 changed files with 82 additions and 22 deletions

View File

@@ -660,6 +660,53 @@ describe("processTimelineResponse", () => {
expect(getUserTexts(result.head)).toEqual(["remote live prompt"]);
});
it("keeps a provider-acknowledged painted-tail prompt newer than the bootstrap page", () => {
const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1");
const live = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "canonical presentation",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
},
},
seq: 51,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica"), submitted],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
sendingClientMessageIds: [],
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 11 },
endCursor: { seq: 50 },
entries: [makeTimelineEntry(50, "canonical answer")],
},
});
expect(getUserTexts([...result.tail, ...result.head])).toEqual(["submitted before bootstrap"]);
expect(result.head[0]).toMatchObject({
kind: "user_message",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
timelineCursor: { epoch: "epoch-1", seq: 51 },
});
});
it("does not duplicate a live row already covered by the bootstrap page", () => {
const live = processAgentStreamEvent({
...baseStreamInput,
@@ -2202,7 +2249,10 @@ describe("processTimelineResponse", () => {
expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]);
expect(result.tail[0]).toEqual(
expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }),
expect.objectContaining({
id: "assistant-newer",
timelineCursor: { epoch: "epoch-1", seq: 3 },
}),
);
expect(result.cursor).toEqual({
epoch: "epoch-1",

View File

@@ -410,16 +410,15 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
return [...olderTail, ...currentTail];
}
return [
...olderTail.slice(0, -1),
{
...olderLast,
text: `${olderLast.text}${currentFirst.text}`,
timestamp: currentFirst.timestamp,
...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}),
},
...currentTail.slice(1),
];
const mergedAssistant: AssistantMessageItem = {
...currentFirst,
text: `${olderLast.text}${currentFirst.text}`,
};
if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) {
mergedAssistant.messageId = olderLast.messageId;
}
return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)];
}
function replaceLiveAssistantWithProjectedText(params: {

View File

@@ -406,6 +406,16 @@ export interface CanonicalStreamReplacementResult {
acknowledgedClientMessageIds: string[];
}
function isAfterCanonicalCoverage(
position: TimelinePosition,
coverage: CanonicalStreamReplacementInput["canonicalCoverage"],
): boolean {
return (
position.epoch === coverage.epoch &&
(coverage.endSeq === null || position.seq > coverage.endSeq)
);
}
function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] {
return [...items.slice(0, index), ...items.slice(index + 1)];
}
@@ -503,9 +513,7 @@ function preserveReplacementHead(
? currentHead.filter(
(item) =>
!item.timelineCursor ||
(item.timelineCursor.epoch === canonicalCoverage.epoch &&
(canonicalCoverage.endSeq === null ||
item.timelineCursor.seq > canonicalCoverage.endSeq)) ||
isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) ||
(item.kind === "assistant_message" &&
canonicalTailAssistant?.kind === "assistant_message" &&
item.text.startsWith(canonicalTailAssistant.text)),
@@ -631,22 +639,25 @@ export function replaceWithCanonicalStream(
nextTail.push(item);
}
const retainedTailMessages: UserMessageItem[] = [];
for (const local of unmatchedTailMessages) {
if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) {
continue;
if (local.clientMessageId && sendingClientMessageIds.has(local.clientMessageId)) {
nextTail.push(local);
} else if (
input.preserveLiveHead &&
local.timelineCursor &&
isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage)
) {
retainedTailMessages.push(local);
}
nextTail.push(local);
}
nextHead = [...retainedTailMessages, ...nextHead];
nextHead = nextHead.filter((item) => {
if (item.kind !== "user_message" || !item.clientMessageId) return true;
if (sendingClientMessageIds.has(item.clientMessageId)) return true;
if (!input.preserveLiveHead || !item.timelineCursor) return false;
return (
item.timelineCursor.epoch === input.canonicalCoverage.epoch &&
(input.canonicalCoverage.endSeq === null ||
item.timelineCursor.seq > input.canonicalCoverage.endSeq)
);
return isAfterCanonicalCoverage(item.timelineCursor, input.canonicalCoverage);
});
const replacement = preserveReplacementHead(