diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 38ad3b5c7de13..b2af7e6728256 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -155,30 +155,21 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { - it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { - const humanTurn = (length: number) => { - const items = Array.from({ length }, (_, index) => makeRow(index)); - items[0] = { - ...items[0]!, - item: { - ...items[0]!.item, - type: "user_message", - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - messageId: MessageId.make("long-human-turn"), - text: "Continue", - attachments: [], - }, - } as OrchestrationV2ProjectedTurnItem; - return items; - }; - - const whole = selectRecentTimelineWindow({ items: humanTurn(180), snapshotSequence: 1 }); - expect(whole.items).toHaveLength(180); - expect(whole.hasMoreHistory).toBe(false); - - const items = humanTurn(THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 100); + it("bounds a single human turn and pages every item exactly once", () => { + const items = Array.from({ length: 180 }, (_, index) => makeRow(index)); + items[0] = { + ...items[0]!, + item: { + ...items[0]!.item, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make("long-human-turn"), + text: "Continue", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; const recent = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); expect(recent.items).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems); const loaded = [...recent.items]; @@ -187,11 +178,7 @@ describe("threadHistoryPaging", () => { expect(pages).toBeLessThan(items.length); const page = selectHistoryPageFromCursor({ items, cursor, snapshotSequence: 1 }); expect(page.items.length).toBeGreaterThan(0); - expect(page.items.length).toBeLessThanOrEqual( - page.items[0]?.item.type === "user_message" - ? THREAD_HISTORY_PAGE_POLICY.maxTurnItems - : THREAD_HISTORY_PAGE_POLICY.maxItems, - ); + expect(page.items.length).toBeLessThanOrEqual(THREAD_HISTORY_PAGE_POLICY.maxItems); loaded.unshift(...page.items); cursor = page.nextCursor; } @@ -214,6 +201,31 @@ describe("threadHistoryPaging", () => { ); }); + it("honors a stricter maxTurnItems on human-turn windows", () => { + const items = Array.from({ length: 60 }, (_, index) => makeRow(index)); + items[0] = { + ...items[0]!, + item: { + ...items[0]!.item, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make("stricter-turn-limit"), + text: "Continue", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + + const recent = selectRecentTimelineWindow({ + items, + snapshotSequence: 1, + policy: { ...THREAD_HISTORY_PAGE_POLICY, maxItems: 100, maxTurnItems: 20 }, + }); + + expect(recent.items).toHaveLength(20); + }); + it("keeps background turns with user turns, with main's 150-turn fan-out ceiling", () => { const items = Array.from({ length: 161 }, (_, turn) => { const row = makeRow(turn * 2); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index dee44adacbb2a..a0fbbe99d4a05 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -7,10 +7,8 @@ import type { } from "@t3tools/contracts"; /** - * Every window stops at the byte budget. A window that holds a user turn also - * stops at `maxUserTurns` or `maxTurnItems` rows, which keeps ordinary turns - * whole (V1 kept 500 activities). Any other window, such as an agent-driven - * thread or the inside of one very long turn, stops at `maxItems` rows. + * Bound every history window by rows and bytes, including a single long human + * turn. Turn limits can stop a window earlier. */ export const THREAD_HISTORY_PAGE_POLICY = { maxUserTurns: 10, @@ -221,7 +219,9 @@ function selectOlderTimelinePage(input: { ? policy.maxUserTurns : undefined; const maxItems = - turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); + turnLimit === undefined + ? policy.maxItems + : Math.min(policy.maxItems, policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { const row = input.items[index]!; const rowBytes = rowCost(row); @@ -242,7 +242,7 @@ function selectOlderTimelinePage(input: { // Inside a turn longer than the window, no selected row starts a user turn. // Later pages and the snapshot query see only such rows and use `maxItems`, // so this page uses it too. - if (userTurns === 0) selected.splice(policy.maxItems); + if (userTurns === 0) selected.splice(maxItems); selected.reverse(); const oldest = selected[0];