Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions apps/server/src/services/threads/timeline-pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,22 @@ export function paginateTimelineRows(
rows: [...rows],
};
}
// Every window ends strictly before its cursor, so no segment at or past the
// cursor was read and none has to be trimmed off here.
const selectedSegments = segments.slice(-page.segmentLimit);
// An older semantic-summary read can overlap its cursor segment so rows that
// cross the raw event boundary are projected with context from both sides.
// The cursor segment is context only; this page owns everything before it.
const cursorIndex =
page.kind === "older"
? segments.findIndex(
(segment) =>
segment.cursor.anchorSeq === page.beforeCursor.anchorSeq &&
segment.cursor.anchorId === page.beforeCursor.anchorId,
)
: -1;
const eligibleSegments =
cursorIndex === -1 ? segments : segments.slice(0, cursorIndex);
const selectedSegments = eligibleSegments.slice(-page.segmentLimit);
const hasOlderRows =
knownHasOlderSegments ?? segments.length > selectedSegments.length;
knownHasOlderSegments ?? eligibleSegments.length > selectedSegments.length;
const oldestSelectedSegment = selectedSegments[0];

return {
Expand Down
80 changes: 59 additions & 21 deletions apps/server/src/services/threads/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
import {
findStoredTimelineWindowByteBudgetFloor,
findTimelineWindowBudgetFloorSequence,
findTimelineSegmentAnchorSequenceAfter,
getStoredEventRowsByParentToolCallIdsDataBytes,
getEnvironment,
findUnfinishedTurnCoveringSequence,
Expand Down Expand Up @@ -1350,25 +1351,30 @@ function resolveTimelineSegmentWindow(
segmentLimit: page.segmentLimit,
threadId,
});
// Rows before the first user-message anchor are the thread prelude. When
// this page reaches the oldest anchor, include that prelude as one extra
// logical segment instead of leaving it unreachable behind no cursor.
const includesThreadPrelude =
bounds.sequenceWindowStart === null &&
precedingAnchors.length <= page.segmentLimit;
return {
// Every cursor names the first sequence the page that issued it covered,
// so this page ends exactly there. Reading up to the *next anchor* past
// the cursor instead — and trimming that segment off after projecting it
// — meant an older page read one whole extra segment beyond its budget:
// on a thread with a 3,900-event turn, 5,513 events against a budget of
// 1,500, all to discard the surplus.
// Transport-window readers end exactly at the cursor. The top-level
// semantic summary can add one segment of projection overlap later,
// after it has deliberately opted out of these event/byte budgets.
beforeSequence: cursor.anchorSeq,
byteWindowSequenceStart:
sequenceCursor?.kind === "byte" ? bounds.sequenceStart : null,
requiresWholeItemClosure:
sequenceCursor !== null || bounds.sequenceWindowStart !== null,
effectiveSegmentLimit: bounds.effectiveSegmentLimit,
effectiveSegmentLimit: includesThreadPrelude
? bounds.effectiveSegmentLimit + 1
: bounds.effectiveSegmentLimit,
hasAnchors: true,
sequenceWindowStart: bounds.sequenceWindowStart,
knownHasOlderSegments:
precedingAnchors.length > bounds.affordableAnchorCount,
oversizedEventPlaceholder: null,
sequenceStart: bounds.sequenceStart,
sequenceStart: includesThreadPrelude ? 0 : bounds.sequenceStart,
};
}

Expand All @@ -1389,18 +1395,23 @@ function resolveTimelineSegmentWindow(
segmentLimit: page.segmentLimit,
threadId,
});
const includesThreadPrelude =
bounds.sequenceWindowStart === null &&
newestAnchors.length <= page.segmentLimit;
return {
beforeSequence: undefined,
byteWindowSequenceStart: null,
requiresWholeItemClosure: bounds.sequenceWindowStart !== null,
effectiveSegmentLimit: bounds.effectiveSegmentLimit,
effectiveSegmentLimit: includesThreadPrelude
? bounds.effectiveSegmentLimit + 1
: bounds.effectiveSegmentLimit,
hasAnchors: true,
sequenceWindowStart: bounds.sequenceWindowStart,
// Budgeted windows read exactly the segments they return, so "is there
// more" comes from the anchor list rather than an over-read segment.
knownHasOlderSegments: newestAnchors.length > bounds.affordableAnchorCount,
oversizedEventPlaceholder: null,
sequenceStart: bounds.sequenceStart,
sequenceStart: includesThreadPrelude ? 0 : bounds.sequenceStart,
};
}

Expand All @@ -1410,16 +1421,35 @@ function selectStandardTimelineEventRows(
page: ThreadTimelinePageRequest,
eventBudget: number,
maxInlineOutputChars: InlineOutputCharLimit,
enforceByteBudget = true,
): TimelineEventRowSelection {
const window = applyTimelineWindowByteBudget(db, {
maxInlineOutputChars,
const segmentWindow = resolveTimelineSegmentWindow(db, {
eventBudget,
page,
threadId: thread.id,
window: resolveTimelineSegmentWindow(db, {
eventBudget,
page,
threadId: thread.id,
}),
});
// A semantic row may start below a user-message cursor and finish above it
// (for example, an assistant message interrupted by a steer). The older
// summary page reads through the following segment anchor, projects that
// overlap, and trims the cursor segment afterward. Nested transport windows
// keep their existing exact upper bound.
const projectionWindow =
!enforceByteBudget && page.kind === "older"
? {
...segmentWindow,
beforeSequence: findTimelineSegmentAnchorSequenceAfter(db, {
sequence: page.beforeCursor.anchorSeq,
threadId: thread.id,
}),
}
: segmentWindow;
const window = enforceByteBudget
? applyTimelineWindowByteBudget(db, {
maxInlineOutputChars,
threadId: thread.id,
window: projectionWindow,
})
: projectionWindow;
if (
!window.hasAnchors &&
window.sequenceWindowStart === null &&
Expand Down Expand Up @@ -1666,6 +1696,12 @@ function buildThreadTimelineInternal(
? createThreadTimelineBuildProfileAccumulator()
: null;
const includeNestedRows = options.includeNestedRows ?? false;
// A default timeline page must never begin at a raw event/byte cut: semantic
// rows can span those cuts, leaving a row as context on one page and outside
// the next. Page the summary resource only at its user-message anchors.
// Nested consumers keep the bounded legacy event windows; completed-turn
// expansion has its own paginated resource.
const useTransportWindows = includeNestedRows;
const includeProviderUnhandledOperations =
options.includeProviderUnhandledOperations;
const eventSelection = measureThreadTimelineStage(
Expand All @@ -1676,8 +1712,11 @@ function buildThreadTimelineInternal(
db,
thread,
options.page,
options.eventBudget,
options.maxInlineOutputChars,
useTransportWindows ? options.eventBudget : Number.MAX_SAFE_INTEGER,
useTransportWindows || thread.status !== "idle"
? options.maxInlineOutputChars
: 0,
useTransportWindows,
),
);
const rawEventRows = eventSelection.rows;
Expand Down Expand Up @@ -2127,8 +2166,7 @@ function buildTimelineTurnSummaryDetailsRange(
// route actually holds, so the parent expansion spends what is left rather
// than a pre-closure estimate of it. The subtraction may go negative, which
// is the safe direction: the parent fetch then stays inside its bounds.
const detailsEventDataBytes =
byteLengthOfStoredEventRows(wholeItemEventRows);
const detailsEventDataBytes = byteLengthOfStoredEventRows(wholeItemEventRows);
const eventRowsWithParentedChildren = ensureTimelineWindowParentedRows(db, {
maxInlineOutputChars: detailsInlineOutputLimit,
outOfBoundsChildDataByteLimit:
Expand Down
12 changes: 9 additions & 3 deletions apps/server/test/public/public-thread-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1668,7 +1668,7 @@ describe("public thread data routes", () => {
});

it(
"expands the newest slice when a large delegation parent completes last",
"expands the canonical summary when a large delegation parent completes last",
async () => {
await withTestHarness(async (harness) => {
const { environment, thread } = seedThreadFixture(harness);
Expand Down Expand Up @@ -1803,12 +1803,18 @@ describe("public thread data routes", () => {
if (!turnRow) {
throw new Error("Expected a turn row");
}
expect(turnRow.sourceSeqStart).toBeGreaterThan(2);
expect(turnRow.sourceSeqStart).toBe(1);
expect(turnRow.sourceSeqEnd).toBe(sequence);

const detailsResponse = await harness.app.request(
`/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=${turnRow.turnId}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`,
`/api/v1/threads/${thread.id}/timeline/turn-details?turnId=${turnRow.turnId}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`,
);
expect(detailsResponse.status).toBe(200);
const details = timelineTurnDetailsResponseSchema.parse(
await readJson(detailsResponse),
);
expect(details.rows.length).toBeGreaterThan(0);
expect(details.nextCursor).not.toBeNull();
});
},
10_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ function findCommandRow(rows: readonly TimelineRow[], command: string) {
function seedRunningTurnWithCommands(harness: TestAppHarness): {
threadId: string;
} {
const { environment, thread } = seedThreadFixture(harness);
const { environment, thread } = seedThreadFixture(harness, {
thread: { status: "active" },
});
const turn = {
threadId: thread.id,
environmentId: environment.id,
Expand Down Expand Up @@ -201,7 +203,9 @@ describe("GET /threads/:id/timeline inline output preview", () => {
describe("GET /threads/:id/timeline inline output preview (tool rows)", () => {
it("previews a large tool result and row-scoped details return it whole", async () => {
await withTestHarness(async (harness) => {
const { environment, thread } = seedThreadFixture(harness);
const { environment, thread } = seedThreadFixture(harness, {
thread: { status: "active" },
});
const turn = {
threadId: thread.id,
environmentId: environment.id,
Expand Down
Loading
Loading