fix(app): preserve assistant order across outline jumps

Live Markdown block promotion discarded timeline positions, so merging a disjoint history window could sort the final block ahead of earlier blocks. Preserve the source position on every promoted block so timeline merges keep the response intact.
This commit is contained in:
Mohamed Boudra
2026-08-04 14:46:30 +02:00
parent b020a1d1ed
commit 8e5b6876b4
3 changed files with 68 additions and 3 deletions
@@ -2715,6 +2715,49 @@ describe("processTimelineResponse", () => {
expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" }));
});
it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
makeAssistantTimelineEvent("First paragraph.\n\nSecond", "assistant-1"),
80,
),
makeStreamReducerEvent(makeAssistantTimelineEvent(" paragraph.", "assistant-1"), 81),
makeStreamReducerEvent(makeTimelineEvent("newest prompt", "user_message"), 82),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
});
expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([
"First paragraph.",
"Second paragraph.",
]);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
currentCursor: live.cursor ?? undefined,
payload: {
...baseTimelineInput.payload,
direction: "before",
mergeWindow: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 40 },
hasOlder: false,
hasNewer: true,
entries: [makeTimelineEntry(1, "oldest prompt", "user_message")],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
"First paragraph.",
"Second paragraph.",
]);
});
it("fills the gap between a retained prompt window and the contiguous tail", () => {
const currentTail: StreamItem[] = [
{
+24
View File
@@ -616,6 +616,30 @@ describe("stream reducer canonical tool calls", () => {
expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length);
});
it("keeps the timeline position on every promoted assistant block", () => {
const timelineCursor = { epoch: "epoch-1", seq: 42 };
const result = applyStreamEvent({
tail: [],
head: [],
event: assistantTimeline("First paragraph.\n\nSecond paragraph.", undefined, "message-1"),
timestamp: new Date("2025-01-01T10:02:00Z"),
timelineCursor,
});
const messages = [...result.tail, ...result.head].filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
expect(messages.map((message) => message.text)).toEqual([
"First paragraph.",
"Second paragraph.",
]);
expect(messages.map((message) => message.timelineCursor)).toEqual([
timelineCursor,
timelineCursor,
]);
});
it("preserves old assistant merge behavior when message ids are absent", () => {
const state = hydrateStreamState([
{
+1 -3
View File
@@ -1573,16 +1573,14 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
const completedBlocks = blocks.slice(0, -1);
const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`;
const promotedItems = completedBlocks.map<AssistantMessageItem>((block, offset) => ({
kind: "assistant_message",
...activeItem,
id: createAssistantBlockId({
groupId: blockGroupId,
blockIndex: firstBlockIndex + offset,
}),
...(activeItem.messageId ? { messageId: activeItem.messageId } : {}),
blockGroupId,
blockIndex: firstBlockIndex + offset,
text: block,
timestamp: activeItem.timestamp,
}));
const nextTail = flushHeadToTail(params.tail, promotedItems);