Keep timeline history duplicate-free across daemon upgrades (#2789)

* fix(app): keep timeline pagination duplicate-free

Older daemons page projected history by canonical sequence ranges, so one projected block can appear in adjacent responses. Accept each block on the page containing its projection anchor while still advancing across empty projected pages.\n\nProjected siblings can share a provider message ID, so render identity also includes the timeline cursor instead of collapsing distinct blocks onto one layout row.

* fix(e2e): preserve isolated daemon launch contract

Keep the source-daemon spawn path recognizable to the repository launch-contract check while selecting the built supervisor only for published package fixtures.

* fix(app): preserve stable timeline item identity
This commit is contained in:
Mohamed Boudra
2026-08-02 12:21:47 +02:00
committed by GitHub
parent 0a106dd35f
commit 26bdb1d171
9 changed files with 407 additions and 44 deletions
@@ -224,6 +224,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
@@ -2540,6 +2541,123 @@ describe("processTimelineResponse", () => {
expect(result.older).toBe("none");
});
it("keeps only projected entries anchored in an older response page", () => {
const currentTail: StreamItem[] = [
{
kind: "user_message",
id: "current-81",
text: "current-81",
timestamp: new Date(81000),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
payload: {
...baseTimelineInput.payload,
direction: "before",
projection: "projected",
epoch: "epoch-1",
startCursor: { seq: 41 },
endCursor: { seq: 80 },
hasOlder: true,
entries: [
makeTimelineEntry(1, "wide assistant", "assistant_message", 80),
makeTimelineEntry(50, "owned by this page", "user_message"),
],
},
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getUserTexts(result.tail)).toEqual(["owned by this page", "current-81"]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 100 });
expect(result.older).toBe("available");
});
it("advances through an older projected page with no anchored entries", () => {
const currentTail: StreamItem[] = [
{
kind: "user_message",
id: "current-81",
text: "current-81",
timestamp: new Date(81000),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
payload: {
...baseTimelineInput.payload,
direction: "before",
projection: "projected",
epoch: "epoch-1",
startCursor: { seq: 41 },
endCursor: { seq: 80 },
hasOlder: true,
entries: [makeTimelineEntry(1, "wide assistant", "assistant_message", 80)],
},
});
expect(result.tail).toBe(currentTail);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 100 });
expect(result.cursorChanged).toBe(true);
expect(result.older).toBe("available");
});
it("gives prepended assistant blocks unique ids across independently hydrated pages", () => {
const currentTail: StreamItem[] = [
{
kind: "user_message",
id: "current-81",
text: "current-81",
timestamp: new Date(81000),
},
{
kind: "assistant_message",
id: "shared-provider-message",
messageId: "shared-provider-message",
text: "newer assistant block",
timestamp: new Date(82000),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
payload: {
...baseTimelineInput.payload,
direction: "before",
projection: "projected",
epoch: "epoch-1",
startCursor: { seq: 41 },
endCursor: { seq: 80 },
hasOlder: true,
entries: [
{
...makeTimelineEntry(50, "older assistant block"),
item: {
type: "assistant_message",
text: "older assistant block",
messageId: "shared-provider-message",
},
},
],
},
});
const assistants = result.tail.filter((item) => item.kind === "assistant_message");
expect(assistants.map((item) => item.messageId)).toEqual([
"shared-provider-message",
"shared-provider-message",
]);
expect(new Set(assistants.map((item) => item.id)).size).toBe(2);
});
it("drops a stale before page anchored before a resume-tail replacement", () => {
const currentTail: StreamItem[] = [
{
@@ -3616,6 +3734,7 @@ describe("processAgentStreamEvents", () => {
payload: {
agentId: "agent-1",
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 186, maxSeq: 186, nextSeq: 187 },
@@ -3660,6 +3779,7 @@ describe("processAgentStreamEvents", () => {
payload: {
agentId: "agent-1",
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 10, maxSeq: 10, nextSeq: 11 },
@@ -74,6 +74,7 @@ export interface ProcessTimelineResponseInput {
payload: {
agentId: string;
direction: TimelineDirection;
projection: "projected" | "canonical";
reset: boolean;
epoch: string;
window: { minSeq: number; maxSeq: number; nextSeq: number };
@@ -786,6 +787,27 @@ function deriveCanonicalAcknowledgements(params: {
return [...acknowledged];
}
function selectEntriesOwnedByTimelinePage(
payload: ProcessTimelineResponseInput["payload"],
): TimelineResponseEntry[] {
// COMPAT(projectedBeforePageOwnership): added in v0.2.6, remove after 2027-02-02
// once the supported daemon floor paginates projected before pages.
if (
payload.direction !== "before" ||
payload.projection !== "projected" ||
!payload.startCursor ||
!payload.endCursor
) {
return payload.entries;
}
const pageStartSeq = payload.startCursor.seq;
const pageEndSeq = payload.endCursor.seq;
return payload.entries.filter(
(entry) => entry.seqStart >= pageStartSeq && entry.seqStart <= pageEndSeq,
);
}
function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -802,7 +824,7 @@ function applyTimelineIncrementalPath(args: {
const sideEffects: TimelineReducerSideEffect[] = [];
let acknowledgedClientMessageIds: string[] = [];
if (timelineUnits.length === 0) {
if (timelineUnits.length === 0 && payload.direction !== "before") {
return {
tail: nextTail,
head: nextHead,
@@ -827,16 +849,33 @@ function applyTimelineIncrementalPath(args: {
currentCursor,
});
if (
payload.direction === "before" &&
cursor &&
currentCursor &&
cursor.startSeq !== currentCursor.startSeq
) {
older = payload.hasOlder ? "available" : "none";
}
if (acceptedUnits.length > 0) {
if (payload.direction === "before") {
older = payload.hasOlder ? "available" : "none";
const olderTail = hydrateStreamState(
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
event,
timestamp,
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
})),
{ source: "canonical" },
{
source: "canonical",
reservedItemIds: new Set(
currentTail.flatMap((item) =>
item.kind === "assistant_message" && item.blockGroupId
? [item.id, item.blockGroupId]
: [item.id],
),
),
},
);
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else {
@@ -915,7 +954,7 @@ export function processTimelineResponse(
// ------------------------------------------------------------------
// Convert entries to timeline units
// ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({
const timelineUnits = selectEntriesOwnedByTimelinePage(payload).map((entry) => ({
seq: entry.seqStart,
seqEnd: entry.seqEnd,
sourceSeqRanges:
+1 -1
View File
@@ -1423,7 +1423,7 @@ export function hydrateStreamState(
timestamp: Date;
timelineCursor?: TimelinePosition;
}>,
options?: { source?: StreamUpdateSource },
options?: { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet<string> },
): StreamItem[] {
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp, timelineCursor }) => {
return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor });