Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging * Avoid full timeline fetches for plain pages
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user