mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
fix(timeline): stop older history pages repeating entries
Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract.
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
||||
scrollTimelineToOldestLoadedEdge,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
@@ -48,6 +50,21 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not repeat an assistant block that spans older history pages", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedRealisticOlderTimeline();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await scrollThroughOlderHistoryPages(page, 3, history);
|
||||
history.expectNoRepeatedAssistantEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AgentTimelineResponseGate {
|
||||
|
||||
export interface OlderTimelinePagesGate {
|
||||
getRequestCount(): number;
|
||||
getRepeatedAssistantEntryCount(): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
@@ -178,6 +179,8 @@ export async function holdAgentOlderTimelinePages(
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedAssistantEntryCount = 0;
|
||||
const assistantEntryKeys = new Set<string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
@@ -213,6 +216,26 @@ export async function holdAgentOlderTimelinePages(
|
||||
payload.direction === "before"
|
||||
) {
|
||||
responseCount += 1;
|
||||
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const projectedEntry = entry as {
|
||||
seqStart?: unknown;
|
||||
seqEnd?: unknown;
|
||||
item?: { type?: unknown };
|
||||
};
|
||||
if (
|
||||
typeof projectedEntry.seqStart !== "number" ||
|
||||
typeof projectedEntry.seqEnd !== "number" ||
|
||||
typeof projectedEntry.item?.type !== "string"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (projectedEntry.item.type !== "assistant_message") continue;
|
||||
const key = `${projectedEntry.seqStart}:${projectedEntry.seqEnd}:${projectedEntry.item.type}`;
|
||||
if (assistantEntryKeys.has(key)) repeatedAssistantEntryCount += 1;
|
||||
else assistantEntryKeys.add(key);
|
||||
}
|
||||
const pageNumber = responseCount;
|
||||
if (releasedPages.has(pageNumber)) {
|
||||
ws.send(message);
|
||||
@@ -229,6 +252,7 @@ export async function holdAgentOlderTimelinePages(
|
||||
|
||||
return {
|
||||
getRequestCount: () => requestCount,
|
||||
getRepeatedAssistantEntryCount: () => repeatedAssistantEntryCount,
|
||||
releasePage(pageNumber) {
|
||||
releasedPages.add(pageNumber);
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
|
||||
@@ -23,6 +23,10 @@ interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
@@ -45,6 +49,7 @@ interface OlderHistoryLoadingOperation {
|
||||
interface OlderHistoryPages {
|
||||
expectRequestedPages(count: number): Promise<void>;
|
||||
expectSettledWithRequestedPages(count: number): Promise<void>;
|
||||
expectNoRepeatedAssistantEntries(): void;
|
||||
releasePage(pageNumber: number): void;
|
||||
}
|
||||
|
||||
@@ -75,6 +80,50 @@ export async function seedLongMockAgentTimeline(
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimelineAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-realistic-older-history-",
|
||||
title: "Realistic older timeline pagination regression",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
const olderTurnCount = 40;
|
||||
for (let index = 0; index < olderTurnCount; index += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
|
||||
const newerTurnCount = 20;
|
||||
for (let index = 0; index < newerTurnCount; index += 1) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
newestPrompt: promptForTurn(newerTurnCount - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrollThroughOlderHistoryPages(
|
||||
page: Page,
|
||||
pageCount: number,
|
||||
history: OlderHistoryPages,
|
||||
): Promise<void> {
|
||||
for (let pageNumber = 0; pageNumber < pageCount; pageNumber += 1) {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(pageNumber + 1);
|
||||
history.releasePage(pageNumber + 1);
|
||||
await history.expectSettledWithRequestedPages(pageNumber + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rememberTimelinePromptPosition(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
@@ -104,7 +153,7 @@ export async function expectTimelinePromptPositionPreserved(
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
export async function openAgentTimeline(page: Page, agent: MockAgentWorkspace): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
@@ -204,7 +253,7 @@ export async function holdNextOlderTimelinePage(
|
||||
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
agent: MockAgentWorkspace,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
|
||||
return {
|
||||
@@ -216,6 +265,9 @@ export async function holdOlderHistoryPages(
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
expectNoRepeatedAssistantEntries() {
|
||||
expect(gate.getRepeatedAssistantEntryCount()).toBe(0);
|
||||
},
|
||||
releasePage(pageNumber) {
|
||||
gate.releasePage(pageNumber);
|
||||
},
|
||||
|
||||
@@ -627,7 +627,63 @@ describe("selectProjectedTimelinePage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
|
||||
test("before limit counts projected entries without repeating a merged assistant", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
...Array.from({ length: 80 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
item: { type: "assistant_message" as const, text: "x" },
|
||||
})),
|
||||
...Array.from({ length: 4 }, (_, index) => ({
|
||||
seq: index + 81,
|
||||
timestamp: new Date(2000 + index).toISOString(),
|
||||
item: { type: "user_message" as const, text: `message ${index + 1}` },
|
||||
})),
|
||||
];
|
||||
|
||||
const newest = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 85,
|
||||
limit: 2,
|
||||
});
|
||||
const middle = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: newest.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
const oldest = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: middle.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
const exhausted = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: oldest.startSeq ?? 0,
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
const pageSummaries = [];
|
||||
for (const page of [newest, middle, oldest]) {
|
||||
pageSummaries.push({
|
||||
entryTypes: page.entries.map((entry) => entry.item.type),
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
hasOlder: page.hasOlder,
|
||||
});
|
||||
}
|
||||
expect(pageSummaries).toEqual([
|
||||
{ entryTypes: ["user_message", "user_message"], startSeq: 83, endSeq: 84, hasOlder: true },
|
||||
{ entryTypes: ["user_message", "user_message"], startSeq: 81, endSeq: 82, hasOlder: true },
|
||||
{ entryTypes: ["assistant_message"], startSeq: 1, endSeq: 80, hasOlder: false },
|
||||
]);
|
||||
expect(exhausted.entries).toEqual([]);
|
||||
});
|
||||
|
||||
test("before pages place a full wide tool on the page containing its projected anchor", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
toolRow(1, "running"),
|
||||
...Array.from({ length: 498 }, (_, index) => ({
|
||||
@@ -638,16 +694,32 @@ describe("selectProjectedTimelinePage", () => {
|
||||
toolRow(500, "completed"),
|
||||
];
|
||||
|
||||
const page = selectProjectedTimelinePage({
|
||||
const newestPage = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 500,
|
||||
limit: 100,
|
||||
});
|
||||
const anchoredPage = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 101,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
|
||||
expect(page.endSeq).toBeLessThan(500);
|
||||
expect(page.hasOlder).toBe(true);
|
||||
expect(newestPage.entries).toHaveLength(100);
|
||||
expect(newestPage.entries.some((entry) => entry.item.type === "tool_call")).toBe(false);
|
||||
expect(newestPage.startSeq).toBe(400);
|
||||
expect(newestPage.endSeq).toBe(499);
|
||||
expect(newestPage.hasOlder).toBe(true);
|
||||
|
||||
expect(anchoredPage.entries).toHaveLength(100);
|
||||
expect(anchoredPage.entries[0]?.item.type).toBe("tool_call");
|
||||
expect(anchoredPage.entries[0]?.seqStart).toBe(1);
|
||||
expect(anchoredPage.entries[0]?.seqEnd).toBe(500);
|
||||
expect(anchoredPage.startSeq).toBe(1);
|
||||
expect(anchoredPage.endSeq).toBe(100);
|
||||
expect(anchoredPage.hasOlder).toBe(false);
|
||||
});
|
||||
|
||||
test("tail page includes a wide tool when its completion is the newest seq", () => {
|
||||
|
||||
@@ -366,16 +366,6 @@ function getTimelineBounds(
|
||||
return { minSeq: first.seq, maxSeq: last.seq };
|
||||
}
|
||||
|
||||
function selectEntriesOverlappingSeqRange(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}): TimelineProjectionEntry[] {
|
||||
return input.entries.filter(
|
||||
(entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq,
|
||||
);
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
@@ -439,6 +429,27 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
@@ -504,11 +515,9 @@ export function selectProjectedTimelinePage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
let startSeq: number;
|
||||
let endSeq: number;
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
rows: input.rows,
|
||||
@@ -523,28 +532,26 @@ export function selectProjectedTimelinePage(input: {
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
} else {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit);
|
||||
}
|
||||
|
||||
if (startSeq > endSeq) {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq });
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries,
|
||||
startSeq,
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user