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
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js";
@@ -436,58 +437,178 @@ describe("selectTimelineWindowByProjectedLimit", () => {
});
});
test("can enforce a hard projected limit when tool lifecycle collapsing is disabled", () => {
test("tail limit treats a repeated running tool call as one projected item", () => {
const rows: AgentTimelineRow[] = [
{
seq: 1,
timestamp: "2026-02-13T00:00:00.000Z",
...Array.from({ length: 6 }, (_, index) => ({
seq: index + 1,
timestamp: `2026-02-13T00:00:00.00${index}Z`,
item: { type: "assistant_message" as const, text: `old ${index}` },
})),
...Array.from({ length: 20 }, (_, index) => ({
seq: index + 7,
timestamp: `2026-02-13T00:00:01.0${index}Z`,
item: {
type: "tool_call",
type: "tool_call" as const,
callId: "call_1",
name: "shell",
status: "running",
status: "running" as const,
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: null,
type: "unknown" as const,
input: { cmd: "sleep 10" },
output: { progress: index },
},
},
},
{
seq: 2,
timestamp: "2026-02-13T00:00:00.100Z",
item: { type: "assistant_message", text: "work" },
},
{
seq: 3,
timestamp: "2026-02-13T00:00:00.200Z",
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: { stdout: "/tmp" },
},
},
},
})),
];
const selected = selectTimelineWindowByProjectedLimit({
rows,
direction: "tail",
limit: 1,
collapseToolLifecycle: false,
limit: 100,
});
expect(selected.minSeq).toBe(3);
expect(selected.maxSeq).toBe(3);
expect(selected.selectedRows.map((row) => row.seq)).toEqual([3]);
expect(selected.projectedEntries).toHaveLength(1);
expect(selected.projectedEntries[0]?.item.type).toBe("tool_call");
const tools = selected.projectedEntries.filter((entry) => entry.item.type === "tool_call");
expect(tools).toHaveLength(1);
expect(tools[0]?.collapsed).toContain("tool_lifecycle");
expect(selected.projectedEntries).toHaveLength(2);
});
});
describe("selectProjectedTimelinePage", () => {
function toolRow(seq: number, status: "running" | "completed"): AgentTimelineRow {
return {
seq,
timestamp: new Date(1000 + seq).toISOString(),
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status,
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: status === "completed" ? { stdout: "done" } : null,
},
},
};
}
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
const rows: AgentTimelineRow[] = [
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
...Array.from({ length: 120 }, (_, index) => toolRow(index + 2, "running")),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.map((entry) => entry.item.type)).toEqual(["user_message", "tool_call"]);
expect(page.entries[1]?.collapsed).toContain("tool_lifecycle");
expect(page.entries[1]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(page.startSeq).toBe(1);
expect(page.endSeq).toBe(121);
expect(page.hasNewer).toBe(false);
});
test("after page includes a full projected tool item when only its update is new", () => {
const rows: AgentTimelineRow[] = [
toolRow(10, "running"),
{
seq: 11,
timestamp: "2026-02-13T00:00:00.011Z",
item: { type: "assistant_message", text: "working" },
},
toolRow(250, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 249,
limit: 100,
});
expect(page.entries).toHaveLength(1);
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(page.entries[0]?.seqStart).toBe(10);
expect(page.entries[0]?.seqEnd).toBe(250);
expect(page.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
]);
expect(page.startSeq).toBe(250);
expect(page.endSeq).toBe(250);
});
test("after page cursor advances only through contiguously covered seqs", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
...Array.from({ length: 101 }, (_, index) => ({
seq: index + 501,
timestamp: new Date(3000 + index).toISOString(),
item: { type: "user_message" as const, text: `later ${index + 501}` },
})),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 0,
limit: 100,
});
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(
page.entries.some((entry) => entry.item.type === "user_message" && entry.seqStart === 101),
).toBe(false);
expect(page.endSeq).toBe(100);
expect(page.hasNewer).toBe(true);
});
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 500,
limit: 100,
});
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBeLessThan(500);
expect(page.hasOlder).toBe(true);
});
test("tail page includes a wide tool when its completion is the newest seq", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 499 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(501, "completed"),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBe(501);
});
});
@@ -28,6 +28,14 @@ interface ProjectedWindowSelection {
maxSeq: number | null;
}
export interface ProjectedTimelinePageSelection {
entries: TimelineProjectionEntry[];
startSeq: number | null;
endSeq: number | null;
hasOlder: boolean;
hasNewer: boolean;
}
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
if (ranges.length === 0) {
return [{ startSeq: seq, endSeq: seq }];
@@ -266,15 +274,11 @@ export function selectTimelineWindowByProjectedLimit(input: {
rows: readonly AgentTimelineRow[];
direction: TimelineLimitDirection;
limit: number;
collapseToolLifecycle?: boolean;
}): ProjectedWindowSelection {
const { rows, direction } = input;
const limit = Math.max(0, Math.floor(input.limit));
const collapseTools = input.collapseToolLifecycle ?? true;
const canonical = makeCanonicalEntries(rows);
const projectedAll = mergeReasoningChunks(
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
);
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
if (projectedAll.length === 0) {
return {
@@ -320,27 +324,25 @@ export function selectTimelineWindowByProjectedLimit(input: {
let { minSeq, maxSeq } = computeWindowBounds(projectedEntries);
let expandedEntries = projectedEntries;
if (collapseTools) {
// Expand to include any projected entries that overlap the selected
// canonical range. Tool lifecycle collapse can produce non-monotonic
// seqEnd values, which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
break;
}
// Expand to include any projected entries that overlap the selected canonical
// range. Tool lifecycle collapse can produce non-monotonic seqEnd values,
// which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
break;
}
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
}
const selectedRows = rows.filter((row) => row.seq >= minSeq && row.seq <= maxSeq);
@@ -353,6 +355,94 @@ export function selectTimelineWindowByProjectedLimit(input: {
};
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
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,
);
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (projectedAll.length === 0 || !bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
const selected = selectTimelineWindowByProjectedLimit({
rows: input.rows,
direction: "tail",
limit,
});
return {
entries: selected.projectedEntries,
startSeq: selected.minSeq,
endSeq: selected.maxSeq,
hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq,
hasNewer: false,
};
}
let startSeq: number;
let endSeq: number;
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
endSeq = limit === 0 ? bounds.maxSeq : Math.min(bounds.maxSeq, cursorSeq + limit);
} 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) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq });
return {
entries,
startSeq,
endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -17,6 +17,7 @@ describe("daemon E2E - timeline window", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await ctx.cleanup();
}, 60_000);
@@ -66,6 +67,10 @@ describe("daemon E2E - timeline window", () => {
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
const expected = "SECOND";
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "next",
});
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
@@ -86,4 +91,271 @@ describe("daemon E2E - timeline window", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("timeline fetch returns one projected in-progress tool call instead of lifecycle deltas", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Projection Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "run the tool",
});
for (let index = 0; index < 120; index += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { progress: index },
},
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
projection: "canonical",
});
const toolEntries = timeline.entries.filter((entry) => entry.item.type === "tool_call");
expect(timeline.projection).toBe("projected");
expect(timeline.entries.map((entry) => entry.item.type)).toEqual([
"user_message",
"tool_call",
]);
expect(toolEntries).toHaveLength(1);
expect(toolEntries[0]?.collapsed).toContain("tool_lifecycle");
expect(toolEntries[0]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(timeline.endCursor?.seq).toBe(121);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch returns the full projected tool item for a new lifecycle update", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Catch-up Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: null,
},
});
for (let seq = 2; seq <= 249; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `background ${seq}`,
});
}
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { stdout: "done" },
},
});
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch: baseline.epoch, seq: 249 },
limit: 100,
projection: "canonical",
});
expect(timeline.entries).toHaveLength(1);
expect(timeline.startCursor?.seq).toBe(250);
expect(timeline.endCursor?.seq).toBe(250);
expect(timeline.entries[0]?.seqStart).toBe(1);
expect(timeline.entries[0]?.seqEnd).toBe(250);
expect(timeline.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 1, endSeq: 1 },
{ startSeq: 250, endSeq: 250 },
]);
expect(timeline.entries[0]?.item.type).toBe("tool_call");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("reset timeline fetch reports older history when the reset slice starts after window min", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Reset HasOlder Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
cursor: { epoch: "stale-epoch", seq: 600 },
limit: 200,
});
expect(timeline.reset).toBe(true);
expect(timeline.staleCursor).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(timeline.window.minSeq);
expect(timeline.hasOlder).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("tail fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tail Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(501);
expect(timeline.endCursor?.seq).toBe(600);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline After Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 300 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(301);
expect(timeline.endCursor?.seq).toBe(400);
expect(timeline.hasNewer).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("before fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Before Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "before",
cursor: { epoch, seq: 501 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(401);
expect(timeline.endCursor?.seq).toBe(500);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
+48 -111
View File
@@ -99,6 +99,7 @@ import type {
AgentManagerEvent,
AgentTimelineCursor,
AgentTimelineFetchDirection,
AgentTimelineFetchResult,
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
@@ -120,8 +121,7 @@ import {
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
selectProjectedTimelinePage,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
@@ -7415,84 +7415,33 @@ export class Session {
});
}
private loadProjectedTimelineWindow(params: {
agentId: string;
direction: AgentTimelineFetchDirection;
cursor: AgentTimelineCursor | undefined;
requestedLimit: number;
timeline: ReturnType<AgentManager["fetchTimeline"]>;
}): {
timeline: ReturnType<AgentManager["fetchTimeline"]>;
selectedRows: ReturnType<typeof selectTimelineWindowByProjectedLimit>["selectedRows"];
minSeq: number | null;
maxSeq: number | null;
} {
const { agentId, direction, cursor, requestedLimit } = params;
let timeline = params.timeline;
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries = projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult;
}): boolean {
const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
return false;
}
return {
timeline,
selectedRows: projectedWindow.selectedRows,
minSeq: projectedWindow.minSeq,
maxSeq: projectedWindow.maxSeq,
};
const firstRow = timeline.rows[0];
if (
firstRow?.item.type === "assistant_message" ||
firstRow?.item.type === "reasoning" ||
firstRow?.item.type === "tool_call"
) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
}
private async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const projection: TimelineProjectionMode = "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
@@ -7508,43 +7457,29 @@ export class Session {
});
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
limit: pageLimit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedResult = this.loadProjectedTimelineWindow({
agentId: msg.agentId,
direction,
cursor,
requestedLimit,
timeline,
});
timeline = projectedResult.timeline;
entries = projectTimelineRows({ rows: projectedResult.selectedRows, mode: projection });
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
hasOlder = projectedResult.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows({ rows: timeline.rows, mode: projection });
}
const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: controlTimeline,
})
? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 })
: controlTimeline;
const projectedPage = selectProjectedTimelinePage({
rows: timeline.rows,
bounds: timeline.window,
direction: controlTimeline.reset ? "tail" : direction,
...(cursor ? { cursorSeq: cursor.seq } : {}),
limit: pageLimit,
});
const startCursor =
projectedPage.startSeq !== null
? { epoch: timeline.epoch, seq: projectedPage.startSeq }
: null;
const endCursor =
projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null;
this.emit({
type: "fetch_agent_timeline_response",
@@ -7555,15 +7490,17 @@ export class Session {
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
reset: controlTimeline.reset,
staleCursor: controlTimeline.staleCursor,
gap: controlTimeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
entries: entries.map((entry) => ({
hasOlder:
projectedPage.hasOlder ||
(projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq),
hasNewer: projectedPage.hasNewer,
entries: projectedPage.entries.map((entry) => ({
provider: snapshot.provider,
item: entry.item,
timestamp: entry.timestamp,