Fix chat history paging around tool updates (#1247)

* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
This commit is contained in:
Mohamed Boudra
2026-05-31 05:38:29 +00:00
committed by GitHub
parent 0e3eb9cf75
commit 3d96ea6354
18 changed files with 890 additions and 256 deletions
@@ -547,6 +547,153 @@ describe("processTimelineResponse", () => {
});
});
it("hydrates a fetched in-progress tool call as one item and streams the next update on top", () => {
const fetched = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 10 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "running", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
expect(getAgentToolCalls(fetched.tail)).toHaveLength(1);
expect(fetched.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
const streamed = processAgentStreamEvent({
...baseStreamInput,
currentTail: fetched.tail,
currentHead: fetched.head,
currentCursor: fetched.cursor ?? undefined,
seq: 251,
epoch: "epoch-1",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call-1",
name: "Read",
status: "completed",
detail: {
type: "read",
filePath: "/tmp/example.ts",
},
error: null,
},
} as AgentStreamEventPayload,
});
const tools = getAgentToolCalls(streamed.tail);
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(streamed.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 251 });
});
it("accepts an after-page projected tool update whose item started before the cursor", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 10,
endSeq: 249,
};
const runningTool = hydrateStreamState(
[
{
event: makeToolCallTimelineEvent("call-1"),
timestamp: new Date(1010),
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: runningTool,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 250 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "completed", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
const tools = getAgentToolCalls(result.tail);
expect(catchUp).toBeUndefined();
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
});
it("replaces an active assistant head when after-page returns a full projected assistant item", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const currentHead = [makeAssistantItem("ABC")];
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 4 },
endCursor: { seq: 5 },
entries: [
{
...makeTimelineEntry(1, "ABCDE"),
seqEnd: 5,
sourceSeqRanges: [{ startSeq: 1, endSeq: 5 }],
collapsed: ["assistant_merge"],
},
],
},
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getAssistantTexts(result.head)).toEqual(["ABCDE"]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 });
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",