From eb8775be1c2c678cdf6a6d8a2c1a0f2fe7598d7b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 15 Jul 2026 21:20:50 -0700 Subject: [PATCH 1/3] Paginate active thread work without losing context --- .../ThreadTimelinePanelContent.test.tsx | 2 + .../timeline/ThreadTimelinePanelContent.tsx | 1 + .../ThreadTimelineRows.actions.test.tsx | 1 + .../thread/timeline/ThreadTimelineRows.tsx | 302 +- ...hreadTimelineRows.turn-pagination.test.tsx | 717 +++++ .../thread/timeline/ThreadTimelineSurface.tsx | 109 +- .../timeline/rows/Delegation.stories.tsx | 8 +- .../timeline/useScrollToSearchedMessage.ts | 2 +- .../useThreadTimelineController.test.tsx | 323 ++- .../timeline/useThreadTimelineController.ts | 146 +- .../toc/ThreadTableOfContents.stories.tsx | 2 +- .../thread/toc/ThreadTableOfContents.test.tsx | 5 +- .../thread/toc/ThreadTableOfContents.tsx | 2 +- ...d-scroll-body.scroll-preservation.test.tsx | 331 ++- .../ui/bottom-anchored-scroll-body.tsx | 135 +- .../cache-owners/cache-owner-registry.test.ts | 17 +- .../cache-owners/realtime-cache-registry.ts | 20 +- .../thread-timeline-cache-owner.ts | 15 + .../mutations/settings-mutations.test.tsx | 4 + apps/app/src/hooks/queries/query-keys.ts | 51 +- .../src/hooks/queries/thread-queries.test.tsx | 106 +- apps/app/src/hooks/queries/thread-queries.ts | 69 +- .../src/hooks/realtime-cache-effects.test.ts | 46 +- apps/app/src/lib/api.ts | 57 +- .../src/lib/side-chat-create-request.test.ts | 6 +- .../src/test/fixtures/thread-timeline-rows.ts | 14 +- .../views/thread-detail/ThreadDetailView.tsx | 2 + .../thread-detail/ThreadTimelinePane.test.tsx | 2 +- .../thread-detail/ThreadTimelinePane.tsx | 4 +- .../useThreadTimelinePages.test.ts | 2 + apps/server/src/routes/threads/data.ts | 62 +- .../threads/timeline-active-work-window.ts | 249 ++ .../src/services/threads/timeline-cache.ts | 23 +- .../services/threads/timeline-pagination.ts | 189 +- .../timeline-turn-details-pagination.ts | 33 + apps/server/src/services/threads/timeline.ts | 1523 +++++++++- .../test/public/public-thread-data.test.ts | 152 +- .../public-thread-timeline-delta.test.ts | 170 +- .../timeline-active-work-window.test.ts | 140 + .../services/threads/timeline-cache.test.ts | 22 + .../timeline-output-truncation.test.ts | 2 + .../threads/timeline-pagination.test.ts | 54 +- .../timeline-parented-pagination.test.ts | 2496 ++++++++++++++++- .../timeline-turn-details-pagination.test.ts | 54 + packages/db/src/data/events.ts | 1844 ++++++++++-- packages/db/src/data/index.ts | 26 + packages/db/test/data/events.test.ts | 1179 +++++++- packages/sdk/src/areas/threads.ts | 44 +- packages/sdk/test/sdk.test.ts | 70 + packages/server-contract/src/api/threads.ts | 99 +- .../server-contract/src/thread-timeline.ts | 38 + .../server-contract/test/contract.test.ts | 157 +- .../thread-view/src/build-thread-timeline.ts | 23 + packages/thread-view/src/timeline-view.ts | 71 +- .../timeline-cli-rendering.snapshots.test.ts | 32 +- .../test/timeline-row-title.test.ts | 3 + .../thread-view/test/timeline-view.test.ts | 44 +- 57 files changed, 10678 insertions(+), 622 deletions(-) create mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineRows.turn-pagination.test.tsx create mode 100644 apps/app/src/hooks/cache-owners/thread-timeline-cache-owner.ts create mode 100644 apps/server/src/services/threads/timeline-active-work-window.ts create mode 100644 apps/server/src/services/threads/timeline-turn-details-pagination.ts create mode 100644 apps/server/test/services/threads/timeline-active-work-window.test.ts create mode 100644 apps/server/test/services/threads/timeline-turn-details-pagination.test.ts diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index 68f14cb0d9..fdadbd9f41 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -51,6 +51,7 @@ vi.mock("./useThreadTimelineController.js", () => ({ hasOlderTimelineRows: false, isLoadingOlderTimelineRows: false, loadOlderTimelineRows: vi.fn(), + paginationSurfaceKey: "thr-test:all", pendingTodos: null, timelineError: null, timelineLoading: false, @@ -102,6 +103,7 @@ function baseTimeline( hasOlderTimelineRows: false, isLoadingOlderTimelineRows: false, loadOlderTimelineRows: vi.fn(), + paginationSurfaceKey: "thr-test:all", pendingTodos: null, timelineError: null, timelineLoading: false, diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx index 0ab22d9da4..adfb58d53e 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx @@ -147,6 +147,7 @@ export function ThreadTimelinePanelContent({ onOpenLocalFileLink={onOpenLocalFileLink} onOpenPluginPanel={onOpenPluginPanel} onTitleAction={onTitleAction} + paginationSurfaceKey={resolvedTimeline.paginationSurfaceKey} projectId={projectId} resolveMentionLink={resolveMentionLink} showOngoingIndicator={showOngoingIndicator} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx index 0ebc31d467..50a9bd5337 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx @@ -118,6 +118,7 @@ function SearchOlderRowsHarness({ onLoadOlderRows={() => { onLoadOlderRows(); setLoadedOlderRows(true); + return true; }} threadRuntimeDisplayStatus="idle" workspaceRootPath={undefined} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index 1e83e4684b..aab8d20f54 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -1,9 +1,11 @@ import { + Fragment, createContext, memo, useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -28,6 +30,7 @@ import type { } from "@bb/domain"; import type { TimelineActivityIntent, + TimelineDelegationChildInterval, TimelineParentChange, TimelineRow, TimelineSystemOperationKind, @@ -88,7 +91,10 @@ import { Button } from "@bb/shared-ui/button"; import { AutoHeightContainer } from "../../ui/height-transition.js"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; -import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; +import { + useBottomAnchoredScroll, + type CapturedScrollAnchor, +} from "@/components/ui/bottom-anchored-scroll-body.js"; import { collectSearchedMessageAncestorRowIds, readSearchMessageTarget, @@ -106,6 +112,7 @@ import { allThreadQueryKeyPrefix, THREAD_QUERY_KEY, THREADS_QUERY_KEY, + threadTimelineTurnSummaryDetailsQueryKey, threadsQueryKey, type ThreadTimelineTurnSummaryDetailsQueryIdentity, } from "@/hooks/queries/query-keys"; @@ -169,7 +176,7 @@ export interface ThreadTimelineRowsProps { resolveUserAttachmentImageSrc?: UserAttachmentImageSrcResolver; hasOlderTimelineRows?: boolean; isLoadingOlderTimelineRows?: boolean; - onLoadOlderRows?: () => Promise | void; + onLoadOlderRows?: () => Promise | boolean; themeType?: ThreadTimelineTheme; timelineRows: TimelineRow[]; threadId?: string; @@ -262,7 +269,9 @@ interface TimelineRowsListProps { compactActivityIntents: boolean; hasOlderTimelineRows?: boolean; isLoadingOlderTimelineRows?: boolean; - onLoadOlderRows?: () => Promise | void; + onLoadOlderRows?: () => Promise | boolean; + renderAfterRows?: () => ReactNode; + renderBeforeRow?: (row: ThreadTimelineViewRow) => ReactNode; rows: readonly ThreadTimelineViewRow[]; scopeActive: boolean; showAssistantMessageActions: boolean; @@ -314,7 +323,17 @@ interface TurnRowBodyProps { showAssistantMessageActions: boolean; } -type LazyTurnRowBodyProps = TurnRowBodyProps; +type LazyTurnRowBodyProps = TurnRowBodyProps & + ( + | { + delegationInterval?: never; + detailKind?: "turn"; + } + | { + delegationInterval: TimelineDelegationChildInterval; + detailKind: "delegation-children"; + } + ); interface TimelineSystemDetailBlockProps { detail: string; @@ -355,6 +374,11 @@ interface TimelineRowTitleRenderStateCache { } interface BuildTurnSummaryDetailsIdentityArgs { + active: boolean; + delegationInterval?: TimelineDelegationChildInterval; + detailKind: "turn" | "delegation-children"; + rowDetailContextItemIds: TimelineViewTurnRow["detailContextItemIds"]; + rowDetailParentToolCallId: TimelineViewTurnRow["detailParentToolCallId"]; rowSourceSeqEnd: TimelineViewTurnRow["sourceSeqEnd"]; rowSourceSeqStart: TimelineViewTurnRow["sourceSeqStart"]; rowThreadId: TimelineViewTurnRow["threadId"]; @@ -587,13 +611,41 @@ function useTimelineSearchExpansionRowIds( } function buildTurnSummaryDetailsIdentity({ + active, + delegationInterval, + detailKind, + rowDetailContextItemIds, + rowDetailParentToolCallId, rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId, }: BuildTurnSummaryDetailsIdentityArgs): ThreadTimelineTurnSummaryDetailsQueryIdentity { + if (detailKind === "delegation-children") { + if ( + delegationInterval === undefined || + rowDetailParentToolCallId === null + ) { + throw new Error("Delegation child details require interval provenance"); + } + return { + active, + detailKind, + directTurnSourceSeqEnd: delegationInterval.directTurnSourceSeqEnd, + directTurnSourceSeqStart: delegationInterval.directTurnSourceSeqStart, + parentToolCallId: rowDetailParentToolCallId, + sourceSeqEnd: rowSourceSeqEnd, + sourceSeqStart: rowSourceSeqStart, + threadId: threadId ?? rowThreadId, + turnId: rowTurnId, + }; + } return { + active, + contextItemIds: rowDetailContextItemIds, + detailKind, + parentToolCallId: rowDetailParentToolCallId, sourceSeqEnd: rowSourceSeqEnd, sourceSeqStart: rowSourceSeqStart, threadId: threadId ?? rowThreadId, @@ -1178,15 +1230,51 @@ function TimelineExpandableBody({ case "work": if (row.workKind === "delegation") { const delegationActive = row.status === "pending"; + const delegationChildPage = row.childPage; + const renderDelegationInterval = ( + interval: TimelineDelegationChildInterval, + ): ReactNode => { + if (!delegationChildPage) return null; + const intervalRow: TimelineViewTurnRow = { + id: `${row.id}:children:${interval.directTurnSourceSeqStart}:${interval.directTurnSourceSeqEnd}`, + threadId: row.threadId, + turnId: delegationChildPage.ownerTurnId, + detailContextItemIds: [], + detailParentToolCallId: delegationChildPage.parentToolCallId, + sourceSeqStart: delegationChildPage.sourceSeqStart, + sourceSeqEnd: delegationChildPage.sourceSeqEnd, + startedAt: row.startedAt, + createdAt: row.createdAt, + kind: "turn", + status: row.status, + summaryCount: 0, + completedAt: row.completedAt, + children: null, + }; + return ( +
+ +
+ ); + }; return (
- {row.childRows.length > 0 ? ( + {row.childRows.length > 0 || delegationChildPage ? ( + delegationChildPage?.intervals + .filter( + (interval) => interval.beforeChildRowId === childRow.id, + ) + .map(renderDelegationInterval) ?? null + } + renderAfterRows={() => + delegationChildPage?.intervals + .filter((interval) => interval.beforeChildRowId === null) + .map(renderDelegationInterval) ?? null + } /> ) : null} {row.output.trim().length > 0 ? ( @@ -1274,6 +1374,8 @@ function TurnRowBody({ function LazyTurnRowBody({ compactActivityIntents, + delegationInterval, + detailKind = "turn", row, showAssistantMessageActions, }: LazyTurnRowBodyProps) { @@ -1281,34 +1383,151 @@ function LazyTurnRowBody({ const { sourceSeqEnd: rowSourceSeqEnd, sourceSeqStart: rowSourceSeqStart, + detailParentToolCallId: rowDetailParentToolCallId, threadId: rowThreadId, turnId: rowTurnId, } = row; const identity = useMemo( () => buildTurnSummaryDetailsIdentity({ + active: row.status === "pending", + delegationInterval, + detailKind, + rowDetailContextItemIds: row.detailContextItemIds, + rowDetailParentToolCallId, rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId, }), - [rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId], + [ + row.status, + delegationInterval, + detailKind, + row.detailContextItemIds, + rowDetailParentToolCallId, + rowSourceSeqEnd, + rowSourceSeqStart, + rowThreadId, + rowTurnId, + threadId, + ], ); const { data: detail, + fetchNextPage, + hasNextPage, isError, + isFetchingNextPage, refetch, } = useThreadTimelineTurnSummaryDetails(identity); + const bottomAnchor = useBottomAnchoredScroll(); + const loadedPageCount = detail?.pages.length ?? 0; + const detailSurfaceIdentity = useMemo( + () => JSON.stringify(threadTimelineTurnSummaryDetailsQueryKey(identity)), + [identity], + ); + const nextPrependRequestIdRef = useRef(0); + const [prependRestoreRequestId, setPrependRestoreRequestId] = useState(0); + const pendingPrependAnchorRef = useRef<{ + anchor: CapturedScrollAnchor; + completed: boolean; + detailSurfaceIdentity: string; + loadedPageCount: number; + requestId: number; + } | null>(null); + useLayoutEffect( + () => () => { + pendingPrependAnchorRef.current?.anchor.cancel(); + pendingPrependAnchorRef.current = null; + nextPrependRequestIdRef.current += 1; + }, + [detailSurfaceIdentity], + ); + useLayoutEffect(() => { + const pending = pendingPrependAnchorRef.current; + if (!pending || pending.requestId !== prependRestoreRequestId) return; + if (pending.detailSurfaceIdentity !== detailSurfaceIdentity) { + pendingPrependAnchorRef.current = null; + pending.anchor.cancel(); + return; + } + if (!pending.completed || loadedPageCount <= pending.loadedPageCount) + return; + pendingPrependAnchorRef.current = null; + pending.anchor.restore(); + }, [detailSurfaceIdentity, loadedPageCount, prependRestoreRequestId]); const handleRetry = useCallback((): void => { void refetch(); }, [refetch]); const rows = detail - ? // Lazy turn-detail children belong to a completed turn — flag the - // scope as closed so trailing work in the children collapses into a - // step-summary at end-of-input, matching the inline-children path. - getViewRows(detail.rows, { closedScope: true }) + ? // Completed detail closes the scope; an active prefix remains open so + // its newest loaded work retains live-frontier presentation. + getViewRows( + detail.pages + .slice() + .reverse() + .flatMap((page) => page.rows), + { closedScope: row.status !== "pending" }, + ) : null; + const handleLoadEarlierWork = useCallback((): void => { + pendingPrependAnchorRef.current?.anchor.cancel(); + const anchor = bottomAnchor?.captureScrollAnchor(); + const requestId = ++nextPrependRequestIdRef.current; + if (anchor) { + pendingPrependAnchorRef.current = { + anchor, + completed: false, + detailSurfaceIdentity, + loadedPageCount, + requestId, + }; + } + void fetchNextPage() + .then((result) => { + const nextLoadedPageCount = result.data?.pages.length ?? 0; + if (result.isError) { + if (pendingPrependAnchorRef.current?.anchor === anchor) { + pendingPrependAnchorRef.current = null; + } + anchor?.cancel(); + // Signed detail cursors intentionally expire when the server's + // signing lifetime changes. Rebuild the active expansion from page + // one so the user can continue paging with fresh cursors. + void refetch(); + return; + } + if (nextLoadedPageCount <= loadedPageCount) { + if (pendingPrependAnchorRef.current?.anchor === anchor) { + pendingPrependAnchorRef.current = null; + } + anchor?.cancel(); + return; + } + const pending = pendingPrependAnchorRef.current; + if ( + pending?.requestId === requestId && + pending.detailSurfaceIdentity === detailSurfaceIdentity + ) { + pending.completed = true; + setPrependRestoreRequestId(requestId); + } + }) + .catch(() => { + if (pendingPrependAnchorRef.current?.anchor === anchor) { + pendingPrependAnchorRef.current = null; + } + anchor?.cancel(); + }); + }, [ + bottomAnchor, + detailSurfaceIdentity, + fetchNextPage, + loadedPageCount, + refetch, + ]); if (!rows && isError) { return ( @@ -1329,16 +1548,31 @@ function LazyTurnRowBody({ } if (rows) { return ( - +
+ {hasNextPage ? ( + + ) : null} + +
); } return ( @@ -1793,6 +2027,8 @@ function TimelineRowsList({ hasOlderTimelineRows, isLoadingOlderTimelineRows, onLoadOlderRows, + renderAfterRows, + renderBeforeRow, rows, scopeActive, showAssistantMessageActions, @@ -1838,18 +2074,22 @@ function TimelineRowsList({ } return ( -
- -
+ + {renderBeforeRow?.(item.row)} +
+ +
+
); })} + {renderAfterRows?.()}
); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.turn-pagination.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.turn-pagination.test.tsx new file mode 100644 index 0000000000..1c6f47ee90 --- /dev/null +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.turn-pagination.test.tsx @@ -0,0 +1,717 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineTurnSummaryDetailsResponse } from "@bb/server-contract"; +import type { ThreadTimelineTurnSummaryDetailsQueryIdentity } from "@/hooks/queries/query-keys"; +import { + commandRow, + conversationRow, + delegationRow, + turnRow, +} from "@/test/fixtures/thread-timeline-rows"; +import { ThreadTimelineRows } from "./ThreadTimelineRows"; +import { ThreadTimelineSurface } from "./ThreadTimelineSurface"; + +const query = vi.hoisted(() => ({ + data: undefined as + | { pages: TimelineTurnSummaryDetailsResponse[] } + | undefined, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: false, + isFetchingNextPage: false, + refetch: vi.fn(), +})); +const captureScrollAnchor = vi.hoisted(() => vi.fn()); +const cancelScrollAnchor = vi.hoisted(() => vi.fn()); +const restoreScrollAnchor = vi.hoisted(() => vi.fn()); +const useDetails = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useThreadTimelineTurnSummaryDetails: useDetails, +})); + +vi.mock("@/components/ui/bottom-anchored-scroll-body.js", () => ({ + useBottomAnchoredScroll: () => ({ captureScrollAnchor }), +})); + +function detailResponse( + text: string, + sequence: number, + hasOlderRows: boolean, +): TimelineTurnSummaryDetailsResponse { + return { + rows: [ + conversationRow({ + id: `assistant-${sequence}`, + role: "assistant", + sourceSeqEnd: sequence, + sourceSeqStart: sequence, + text, + threadId: "thread-1", + turnId: "turn-1", + }), + ], + timelinePage: { + hasOlderRows, + olderCursor: hasOlderRows + ? { anchorId: `assistant-${sequence}`, anchorSeq: sequence } + : null, + }, + }; +} + +function pendingSummaryElement(sourceSeqStart = 2) { + return ( + + + + ); +} + +function renderPendingSummary() { + return render(pendingSummaryElement()); +} + +describe("lazy turn-summary pagination", () => { + beforeEach(() => { + query.data = { pages: [] }; + query.fetchNextPage.mockReset(); + query.hasNextPage = false; + query.isError = false; + query.isFetchingNextPage = false; + query.refetch.mockReset(); + captureScrollAnchor.mockReset(); + cancelScrollAnchor.mockReset(); + restoreScrollAnchor.mockReset(); + captureScrollAnchor.mockReturnValue({ + cancel: cancelScrollAnchor, + restore: restoreScrollAnchor, + }); + query.fetchNextPage.mockResolvedValue({ data: query.data, isError: false }); + useDetails.mockReset(); + useDetails.mockImplementation(() => query); + }); + + afterEach(() => cleanup()); + + it("restores only after the requested older page commits", async () => { + const newerPage = detailResponse("Newer loaded work", 40, true); + const realtimePage = detailResponse("Realtime work", 41, true); + const olderPage = detailResponse("Older loaded work", 20, false); + query.data = { + pages: [newerPage], + }; + query.hasNextPage = true; + let resolveFetch: (result: { + data: typeof query.data; + isError: boolean; + }) => void = () => {}; + query.fetchNextPage.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const view = renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + expect(captureScrollAnchor).toHaveBeenCalledTimes(1); + expect(query.fetchNextPage).toHaveBeenCalledTimes(1); + + // A realtime refetch can change the active page while the older request is + // pending, but it must not consume this request's anchor. + query.data = { pages: [realtimePage] }; + view.rerender(pendingSummaryElement()); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + + query.data = { pages: [realtimePage, olderPage] }; + resolveFetch({ data: query.data, isError: false }); + await waitFor(() => expect(restoreScrollAnchor).toHaveBeenCalledTimes(1)); + + const older = screen.getByText("Older loaded work"); + const realtime = screen.getByText("Realtime work"); + expect( + older.compareDocumentPosition(realtime) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("waits for the detail-page render when the request completes first", async () => { + const newerPage = detailResponse("Newer loaded work", 40, true); + const olderPage = detailResponse("Older loaded work", 20, false); + query.data = { pages: [newerPage] }; + query.hasNextPage = true; + let resolveFetch: (result: { + data: { pages: TimelineTurnSummaryDetailsResponse[] }; + isError: boolean; + }) => void = () => {}; + query.fetchNextPage.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const view = renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + resolveFetch({ data: { pages: [newerPage, olderPage] }, isError: false }); + await Promise.resolve(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + + query.data = { pages: [newerPage, olderPage] }; + view.rerender(pendingSummaryElement()); + await waitFor(() => expect(restoreScrollAnchor).toHaveBeenCalledTimes(1)); + }); + + it("cancels an in-flight anchor when the detail identity changes", async () => { + const newerPage = detailResponse("Newer loaded work", 40, true); + const olderPage = detailResponse("Older loaded work", 20, false); + query.data = { pages: [newerPage] }; + query.hasNextPage = true; + let resolveFetch: (result: { + data: { pages: TimelineTurnSummaryDetailsResponse[] }; + isError: boolean; + }) => void = () => {}; + query.fetchNextPage.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const view = renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + view.rerender(pendingSummaryElement(3)); + expect(cancelScrollAnchor).toHaveBeenCalledTimes(1); + + resolveFetch({ data: { pages: [newerPage, olderPage] }, isError: false }); + await Promise.resolve(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + }); + + it("loads direct child summaries from a delegation-owned boundary", () => { + query.data = { + pages: [ + { + rows: [ + turnRow({ + children: null, + id: "child-summary", + sourceSeqEnd: 30, + sourceSeqStart: 20, + status: "completed", + threadId: "thread-1", + turnId: "child-turn", + }), + ], + timelinePage: { hasOlderRows: false, olderCursor: null }, + }, + ], + }; + render( + + + , + ); + + expect(useDetails).toHaveBeenCalledWith( + expect.objectContaining({ + detailKind: "delegation-children", + directTurnSourceSeqEnd: 30, + directTurnSourceSeqStart: 20, + parentToolCallId: "delegation-1", + sourceSeqEnd: 30, + sourceSeqStart: 20, + turnId: "parent-turn", + }), + ); + expect(screen.getByText(/Worked for/)).toBeTruthy(); + }); + + it("renders each interval once between projected retained summaries, including before trailing work", () => { + const intervalQueries = new Map([ + ["20:20", detailResponse("Cross-turn child one", 20, false)], + ["40:40", detailResponse("Cross-turn child two", 40, false)], + ]); + useDetails.mockImplementation( + (identity: ThreadTimelineTurnSummaryDetailsQueryIdentity) => ({ + ...query, + data: + identity.detailKind === "delegation-children" + ? { + pages: [ + intervalQueries.get( + `${identity.directTurnSourceSeqStart}:${identity.directTurnSourceSeqEnd}`, + ), + ].filter( + (page): page is TimelineTurnSummaryDetailsResponse => + page !== undefined, + ), + } + : query.data, + }), + ); + const view = render( + + + , + ); + + const nestedList = view.container.querySelector( + '[data-timeline-row-list="nested"]', + ); + if (!nestedList) throw new Error("Expected delegation child row list"); + const directChildren = Array.from(nestedList.children); + expect( + directChildren.map((element) => + element.hasAttribute("data-timeline-row-id") ? "summary" : "interval", + ), + ).toEqual(["summary", "interval", "summary", "interval", "summary"]); + expect( + directChildren + .filter((element) => element.hasAttribute("data-timeline-row-id")) + .map((element) => element.getAttribute("data-timeline-row-id")), + ).toEqual([ + "thread-1:parent-turn:work-summary:retained-1a", + "thread-1:parent-turn:work-summary:retained-2a", + "thread-1:parent-turn:work-summary:retained-3a", + ]); + expect(directChildren[1]?.textContent).toContain("Cross-turn child one"); + expect(directChildren[3]?.textContent).toContain("Cross-turn child two"); + expect(screen.getAllByText("Cross-turn child one")).toHaveLength(1); + expect(screen.getAllByText("Cross-turn child two")).toHaveLength(1); + expect(useDetails).toHaveBeenCalledWith( + expect.objectContaining({ + detailKind: "delegation-children", + directTurnSourceSeqEnd: 20, + directTurnSourceSeqStart: 20, + }), + ); + expect(useDetails).toHaveBeenCalledWith( + expect.objectContaining({ + detailKind: "delegation-children", + directTurnSourceSeqEnd: 40, + directTurnSourceSeqStart: 40, + }), + ); + }); + + it("cancels the prepend anchor when loading earlier work fails", async () => { + query.data = { pages: [detailResponse("Newest work", 40, true)] }; + query.hasNextPage = true; + query.fetchNextPage.mockRejectedValue(new Error("network failure")); + renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + + await waitFor(() => expect(cancelScrollAnchor).toHaveBeenCalledTimes(1)); + }); + + it("rebuilds detail pages when a signed older cursor expires", async () => { + query.data = { pages: [detailResponse("Newest work", 40, true)] }; + query.hasNextPage = true; + query.fetchNextPage.mockResolvedValue({ + data: query.data, + isError: true, + }); + renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + + await waitFor(() => expect(cancelScrollAnchor).toHaveBeenCalledTimes(1)); + expect(query.refetch).toHaveBeenCalledTimes(1); + }); + + it("cancels an in-flight prepend anchor when the summary unmounts", async () => { + query.data = { pages: [detailResponse("Newest work", 40, true)] }; + query.hasNextPage = true; + let resolveFetch: (result: { + data: typeof query.data; + isError: boolean; + }) => void = () => {}; + query.fetchNextPage.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const view = renderPendingSummary(); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier work" })); + view.unmount(); + expect(cancelScrollAnchor).toHaveBeenCalledTimes(1); + + resolveFetch({ data: query.data, isError: false }); + await Promise.resolve(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + }); + + it("shows the loading state and permits retry after an initial failure", () => { + query.data = { pages: [detailResponse("Newest work", 40, true)] }; + query.hasNextPage = true; + query.isFetchingNextPage = true; + renderPendingSummary(); + expect( + screen + .getByRole("button", { name: "Loading earlier work…" }) + .hasAttribute("disabled"), + ).toBe(true); + + cleanup(); + query.data = undefined; + query.hasNextPage = false; + query.isError = true; + query.isFetchingNextPage = false; + renderPendingSummary(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(query.refetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("top-level timeline pagination", () => { + beforeEach(() => { + captureScrollAnchor.mockReset(); + cancelScrollAnchor.mockReset(); + restoreScrollAnchor.mockReset(); + captureScrollAnchor.mockReturnValue({ + cancel: cancelScrollAnchor, + restore: restoreScrollAnchor, + }); + }); + + afterEach(() => cleanup()); + + it("restores after the requested top-level prepend commits", async () => { + const newer = conversationRow({ + id: "newer-message", + role: "assistant", + sourceSeqEnd: 40, + sourceSeqStart: 40, + text: "Newer message", + threadId: "thread-1", + turnId: "turn-1", + }); + const older = conversationRow({ + id: "older-message", + role: "user", + sourceSeqEnd: 20, + sourceSeqStart: 20, + text: "Older message", + threadId: "thread-1", + turnId: "turn-0", + }); + let resolveLoad: (appended: boolean) => void = () => {}; + const onLoadOlderRows = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const surface = ( + timelineRows: (typeof newer)[], + hasOlderTimelineRows = true, + ) => ( + + + + ); + const view = render(surface([newer])); + + fireEvent.click( + screen.getByRole("button", { name: "Load older messages" }), + ); + expect(captureScrollAnchor).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onLoadOlderRows).toHaveBeenCalledTimes(1)); + + view.rerender(surface([older, newer], false)); + expect( + screen.queryByRole("button", { name: "Load older messages" }), + ).toBeNull(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + resolveLoad(true); + + await waitFor(() => expect(restoreScrollAnchor).toHaveBeenCalledTimes(1)); + }); + + it("waits for a row commit when the request completes first", async () => { + const newer = conversationRow({ + id: "newer-message", + role: "assistant", + sourceSeqEnd: 40, + sourceSeqStart: 40, + text: "Newer message", + threadId: "thread-1", + turnId: "turn-1", + }); + const older = conversationRow({ + id: "older-message", + role: "user", + sourceSeqEnd: 20, + sourceSeqStart: 20, + text: "Older message", + threadId: "thread-1", + turnId: "turn-0", + }); + const onLoadOlderRows = vi.fn().mockResolvedValue(true); + const surface = (timelineRows: (typeof newer)[]) => ( + + + + ); + const view = render(surface([newer])); + + fireEvent.click( + screen.getByRole("button", { name: "Load older messages" }), + ); + await waitFor(() => expect(onLoadOlderRows).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + + view.rerender(surface([older, newer])); + await waitFor(() => expect(restoreScrollAnchor).toHaveBeenCalledTimes(1)); + }); + + it("cancels an old request when the pagination surface changes", async () => { + const newer = conversationRow({ + id: "newer-message", + role: "assistant", + sourceSeqEnd: 40, + sourceSeqStart: 40, + text: "Newer message", + threadId: "thread-1", + turnId: "turn-1", + }); + let resolveLoad: (appended: boolean) => void = () => {}; + const onLoadOlderRows = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const surface = (paginationSurfaceKey: string) => ( + + + + ); + const view = render(surface("surface-1")); + + fireEvent.click( + screen.getByRole("button", { name: "Load older messages" }), + ); + await waitFor(() => expect(onLoadOlderRows).toHaveBeenCalledTimes(1)); + view.rerender(surface("surface-2")); + expect(cancelScrollAnchor).toHaveBeenCalledTimes(1); + + resolveLoad(true); + await Promise.resolve(); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + }); + + it("cancels immediately when the loader reports an empty page", async () => { + const newer = conversationRow({ + id: "newer-message", + role: "assistant", + sourceSeqEnd: 40, + sourceSeqStart: 40, + text: "Newer message", + threadId: "thread-1", + turnId: "turn-1", + }); + render( + + false} + showOngoingIndicator={false} + timelineError={false} + timelineRows={[newer]} + threadId="thread-1" + threadRuntimeDisplayStatus="idle" + workspaceRootPath={undefined} + /> + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Load older messages" }), + ); + await waitFor(() => expect(cancelScrollAnchor).toHaveBeenCalledTimes(1)); + expect(restoreScrollAnchor).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index c75e3a9690..afd66916ad 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -17,7 +19,10 @@ import { ConversationTimeline } from "@/components/ui/conversation.js"; import { HeightTransition } from "@/components/ui/height-transition.js"; import { Icon } from "@bb/shared-ui/icon"; import { Skeleton } from "@bb/shared-ui/skeleton"; -import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; +import { + useBottomAnchoredScroll, + type CapturedScrollAnchor, +} from "@/components/ui/bottom-anchored-scroll-body.js"; import { usePreferredTheme } from "@/hooks/useTheme"; import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images"; import { ThreadTimelineRows } from "./ThreadTimelineRows.js"; @@ -57,12 +62,13 @@ export interface ThreadTimelineSurfaceProps { onSendToMainMessage?: ThreadTimelineSendToMainMessageHandler; onSelectionAddToChat?: ThreadTimelineSelectionAddToChatHandler; onSelectionReplyInSideChat?: ThreadTimelineSelectionReplyInSideChatHandler; - onLoadOlderRows?: () => Promise | void; + onLoadOlderRows?: () => Promise | boolean; onOpenLink?: ThreadTimelineLinkHandler; onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler; onOpenPluginPanel?: ThreadTimelineOpenPluginPanelHandler; onTitleAction?: TimelineTitleActionResolver; projectId?: string; + paginationSurfaceKey?: string; resolveMentionLink?: PromptMentionLinkResolver; showOngoingIndicator: boolean; ongoingIndicatorLabel?: string; @@ -160,6 +166,7 @@ export function ThreadTimelineSurface({ onOpenLocalFileLink, onOpenPluginPanel, onTitleAction, + paginationSurfaceKey, projectId, resolveMentionLink, showOngoingIndicator, @@ -193,6 +200,90 @@ export function ThreadTimelineSurface({ stoppingAnchorAt, threadId, }); + const oldestTimelineRowId = timelineRowsWithPendingStop[0]?.id ?? null; + const olderPageSurfaceIdentity = paginationSurfaceKey ?? threadId; + const bottomAnchor = useBottomAnchoredScroll(); + const nextOlderPageRequestIdRef = useRef(0); + const [completedOlderPageRequestId, setCompletedOlderPageRequestId] = + useState(0); + const pendingOlderPageRef = useRef<{ + anchor: CapturedScrollAnchor; + oldestTimelineRowId: string | null; + requestId: number; + surfaceIdentity: string; + completed: boolean; + appended: boolean | null; + } | null>(null); + useLayoutEffect(() => { + const pending = pendingOlderPageRef.current; + if (!pending || pending.requestId !== completedOlderPageRequestId) return; + if (pending.surfaceIdentity !== olderPageSurfaceIdentity) { + pendingOlderPageRef.current = null; + pending.anchor.cancel(); + return; + } + if (!pending.completed) return; + if (pending.appended === false) { + pendingOlderPageRef.current = null; + pending.anchor.cancel(); + return; + } + if (oldestTimelineRowId !== pending.oldestTimelineRowId) { + pendingOlderPageRef.current = null; + pending.anchor.restore(); + } + }, [ + completedOlderPageRequestId, + oldestTimelineRowId, + olderPageSurfaceIdentity, + ]); + useLayoutEffect( + () => () => { + pendingOlderPageRef.current?.anchor.cancel(); + pendingOlderPageRef.current = null; + }, + [olderPageSurfaceIdentity], + ); + const handleLoadOlderRows = useCallback(() => { + if (!onLoadOlderRows) return; + pendingOlderPageRef.current?.anchor.cancel(); + const anchor = bottomAnchor?.captureScrollAnchor(); + const requestId = ++nextOlderPageRequestIdRef.current; + if (anchor) { + pendingOlderPageRef.current = { + anchor, + appended: null, + completed: false, + oldestTimelineRowId, + requestId, + surfaceIdentity: olderPageSurfaceIdentity, + }; + } + Promise.resolve() + .then(() => onLoadOlderRows()) + .then((appended) => { + const pending = pendingOlderPageRef.current; + if ( + pending?.requestId === requestId && + pending.surfaceIdentity === olderPageSurfaceIdentity + ) { + pending.appended = appended; + pending.completed = true; + setCompletedOlderPageRequestId(requestId); + } + }) + .catch(() => { + if (pendingOlderPageRef.current?.requestId === requestId) { + pendingOlderPageRef.current = null; + } + anchor?.cancel(); + }); + }, [ + bottomAnchor, + oldestTimelineRowId, + onLoadOlderRows, + olderPageSurfaceIdentity, + ]); const showLoadOlderRows = hasOlderTimelineRows && onLoadOlderRows !== undefined && @@ -205,7 +296,7 @@ export function ThreadTimelineSurface({ {showLoadOlderRows ? ( ) : null} {isThreadTimelinePending ? ( @@ -267,24 +358,18 @@ export function ThreadTimelineSurface({ function LoadOlderMessagesButton({ isLoadingOlderTimelineRows, - onLoadOlderRows, + onClick, }: { isLoadingOlderTimelineRows: boolean; - onLoadOlderRows: () => Promise | void; + onClick: () => void; }) { - const bottomAnchor = useBottomAnchoredScroll(); - const handleClick = useCallback(() => { - bottomAnchor?.captureScrollAnchor(); - void onLoadOlderRows(); - }, [bottomAnchor, onLoadOlderRows]); - return (
+ + + + + ); +} + +function RevealClampedControl({ rowId }: { rowId: string }) { + const bottomAnchor = useBottomAnchoredScroll(); + return ( + + ); +} + function renderTimeline({ threadId, rowIds, + nestedRowIdsByParent = {}, + revealClampedRowId, + showPrependAnchorControl = false, showScrollToBottomControl = false, }: RenderArgs) { const view = render( @@ -120,9 +195,18 @@ function renderTimeline({ scrollAnchorThreadId={threadId} > {showScrollToBottomControl ? : null} + {showPrependAnchorControl ? : null} + {revealClampedRowId ? ( + + ) : null} {rowIds.map((rowId) => (
{rowId} + {(nestedRowIdsByParent[rowId] ?? []).map((nestedRowId) => ( +
+ {nestedRowId} +
+ ))}
))} , @@ -132,7 +216,11 @@ function renderTimeline({ view.container.querySelector(`.${SCROLL_AREA_CLASS}`), ); const rowElements = new Map(); - for (const rowId of rowIds) { + const allRowIds = [ + ...rowIds, + ...rowIds.flatMap((rowId) => nestedRowIdsByParent[rowId] ?? []), + ]; + for (const rowId of allRowIds) { rowElements.set( rowId, requireHTMLElement( @@ -155,6 +243,8 @@ function readAnchor(threadId: string) { beforeEach(() => { ResizeObserverMock.instances = []; + nextAnimationFrameId = 1; + animationFrameCallbacks = new Map(); vi.stubGlobal("ResizeObserver", ResizeObserverMock); installAnimationFrameMocks(); }); @@ -171,6 +261,235 @@ afterEach(() => { }); describe("BottomAnchoredScrollBody scroll preservation", () => { + it("does not apply a cancelled prepend anchor to unrelated later growth", () => { + const { getByRole, scrollArea } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b"], + showPrependAnchorControl: true, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + fireEvent.click(getByRole("button", { name: "Cancel prepend" })); + setScrollMetrics(scrollArea, { + scrollHeight: 450, + clientHeight: 100, + scrollTop: 150, + }); + fireEvent.click(getByRole("button", { name: "Rerender" })); + + expect(scrollArea.scrollTop).toBe(150); + }); + + it("restores the captured row only when the matching prepend commits", () => { + const { getByRole, scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + showPrependAnchorControl: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + const rowB = requireHTMLElement(rowElements.get("row-b")!); + const rowBRect = vi + .spyOn(rowB, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, -20, 100, 100)); + mockRowRect(requireHTMLElement(rowElements.get("row-c")!), { + top: 80, + bottom: 180, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + + // Realtime growth below the viewport does not consume or apply the anchor. + setScrollMetrics(scrollArea, { + scrollHeight: 450, + clientHeight: 100, + scrollTop: 150, + }); + fireEvent.click(getByRole("button", { name: "Rerender" })); + expect(scrollArea.scrollTop).toBe(150); + + // The requested prepend moves the captured row down by 100px. Explicitly + // restoring that request preserves the row's prior viewport position. + rowBRect.mockReturnValue(new DOMRect(0, 80, 100, 100)); + fireEvent.click(getByRole("button", { name: "Restore prepend" })); + expect(scrollArea.scrollTop).toBe(250); + }); + + it("anchors a visible nested row and suppresses later sticky-bottom restoration", () => { + const { getByRole, scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["turn-row"], + nestedRowIdsByParent: { + "turn-row": ["nested-older", "nested-visible"], + }, + revealClampedRowId: "nested-visible", + showPrependAnchorControl: true, + }); + mockScrollAreaRect(scrollArea); + const turnRow = requireHTMLElement(rowElements.get("turn-row")!); + mockRowRect(turnRow, { top: -200, bottom: 200 }); + mockRowRect(requireHTMLElement(rowElements.get("nested-older")!), { + top: -120, + bottom: -20, + }); + const visibleNestedRow = requireHTMLElement( + rowElements.get("nested-visible")!, + ); + const visibleNestedRect = vi + .spyOn(visibleNestedRow, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, -20, 100, 100)); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + + // Prepending inside the expanded turn leaves the outer wrapper's top + // unchanged but moves the first visible child down by 100px. + visibleNestedRect.mockReturnValue(new DOMRect(0, 80, 100, 100)); + setScrollMetrics(scrollArea, { + scrollHeight: 500, + clientHeight: 100, + scrollTop: 300, + }); + fireEvent.click(getByRole("button", { name: "Restore prepend" })); + expect(scrollArea.scrollTop).toBe(400); + // Browsers emit scroll for the programmatic correction. Even though the + // corrected geometry is exactly at bottom, the request remains detached. + fireEvent.scroll(scrollArea); + flushAnimationFrames(); + expect(scrollArea.scrollTop).toBe(400); + + // Later realtime growth and its ResizeObserver pass must not re-arm the + // pre-request sticky-bottom state and pull the viewport to the new bottom. + setScrollMetrics(scrollArea, { + scrollHeight: 650, + clientHeight: 100, + scrollTop: 400, + }); + getLatestResizeObserver().trigger(); + flushAnimationFrames(); + expect(scrollArea.scrollTop).toBe(400); + + // An explicit clamped reveal that lands at max opts back into following. + visibleNestedRect.mockReturnValue(new DOMRect(0, 200, 100, 100)); + fireEvent.click(getByRole("button", { name: "Reveal clamped" })); + fireEvent.scroll(scrollArea); + setScrollMetrics(scrollArea, { + scrollHeight: 750, + clientHeight: 100, + scrollTop: 550, + }); + getLatestResizeObserver().trigger(); + flushAnimationFrames(); + expect(scrollArea.scrollTop).toBe(650); + }); + + it("keeps the visible ancestor when a nested row is only a bottom sliver", () => { + const { getByRole, scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["turn-row"], + nestedRowIdsByParent: { "turn-row": ["nested-sliver"] }, + showPrependAnchorControl: true, + }); + mockScrollAreaRect(scrollArea); + const turnRow = requireHTMLElement(rowElements.get("turn-row")!); + mockRowRect(turnRow, { top: -20, bottom: 110 }); + const nestedSliver = requireHTMLElement(rowElements.get("nested-sliver")!); + const nestedRect = vi + .spyOn(nestedSliver, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 90, 100, 20)); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + nestedRect.mockReturnValue(new DOMRect(0, 140, 100, 20)); + fireEvent.click(getByRole("button", { name: "Restore prepend" })); + + // The outer content is what occupied the viewport; preserving a 10px child + // sliver would have shifted scrollTop by 50px. + expect(scrollArea.scrollTop).toBe(150); + }); + + it("does not restore a visible nested row after user scroll intent", () => { + const { getByRole, scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["turn-row"], + nestedRowIdsByParent: { "turn-row": ["nested-visible"] }, + showPrependAnchorControl: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("turn-row")!), { + top: -200, + bottom: 200, + }); + const visibleNestedRow = requireHTMLElement( + rowElements.get("nested-visible")!, + ); + const visibleNestedRect = vi + .spyOn(visibleNestedRow, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, -20, 100, 100)); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + fireEvent.wheel(scrollArea, { deltaY: -20 }); + visibleNestedRect.mockReturnValue(new DOMRect(0, 80, 100, 100)); + fireEvent.click(getByRole("button", { name: "Restore prepend" })); + + expect(scrollArea.scrollTop).toBe(150); + }); + + it("does not restore a prepend anchor after user scroll intent", () => { + const { getByRole, scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b"], + showPrependAnchorControl: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + const rowB = requireHTMLElement(rowElements.get("row-b")!); + const rowBRect = vi + .spyOn(rowB, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, -20, 100, 100)); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.click(getByRole("button", { name: "Arm prepend" })); + fireEvent.wheel(scrollArea, { deltaY: -20 }); + rowBRect.mockReturnValue(new DOMRect(0, 80, 100, 100)); + fireEvent.click(getByRole("button", { name: "Restore prepend" })); + + expect(scrollArea.scrollTop).toBe(150); + }); + it("captures the top-most visible row when scrolled mid-timeline", () => { const { scrollArea, rowElements } = renderTimeline({ threadId: "thread-a", diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index bab6d6b0f4..d534a7d92a 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -46,9 +46,14 @@ export interface BottomAnchorContextValue { scrollElementIntoViewClampedToMaxScroll: ( args: ScrollElementIntoViewClampedToMaxScrollArgs, ) => void; - // Snapshot the scroll area so the next height growth (e.g. prepending older - // messages) keeps the visible row at the same Y position instead of jumping. - captureScrollAnchor: () => void; + // Capture the visible row itself so callers can restore after their specific + // prepend commits. Unrelated streaming growth cannot consume this snapshot. + captureScrollAnchor: () => CapturedScrollAnchor; +} + +export interface CapturedScrollAnchor { + cancel: () => void; + restore: () => void; } export interface BottomAnchoredScrollBodyProps { @@ -161,6 +166,12 @@ interface TopMostVisibleRow { offsetWithinRow: number; } +interface VisibleTimelineRowElement { + element: HTMLElement; + rect: DOMRect; + rowId: string; +} + // The top-most timeline row whose bottom edge is below the scroll area's top // edge — i.e. the first row still (at least partially) visible. `offsetWithinRow` // is how far the scroll area's top sits past that row's top, so restore can @@ -169,20 +180,50 @@ function getTopMostVisibleRow( scrollArea: HTMLElement, ): TopMostVisibleRow | null { const scrollAreaTop = scrollArea.getBoundingClientRect().top; + const scrollAreaBottom = scrollArea.getBoundingClientRect().bottom; const rows = scrollArea.querySelectorAll( TIMELINE_ROW_ID_SELECTOR, ); + const visibleRows: VisibleTimelineRowElement[] = []; for (const row of rows) { const rowId = row.dataset.timelineRowId; if (!rowId) continue; const rowRect = row.getBoundingClientRect(); if (rowRect.bottom <= scrollAreaTop + 1) continue; - return { - rowId, - offsetWithinRow: Math.max(0, scrollAreaTop - rowRect.top), - }; + if (rowRect.top >= scrollAreaBottom - 1) continue; + visibleRows.push({ element: row, rect: rowRect, rowId }); } - return null; + // Expanded timeline rows wrap their nested row list, so DOM preorder sees + // the outer turn before the child the user is actually reading. Prefer leaf + // rows once a nested row reaches the viewport top; otherwise prepending + // inside the outer wrapper would leave its top unchanged and produce no + // compensation. A child visible only as a lower-viewport sliver does not + // displace the ancestor content the user is still reading. + const visibleLeafRows = visibleRows.filter( + (candidate) => + !visibleRows.some( + (other) => + other !== candidate && + candidate.element.contains(other.element) && + other.rect.top <= scrollAreaTop + 1 && + other.rect.bottom > scrollAreaTop + 1, + ), + ); + const topMost = visibleLeafRows.reduce( + (current, candidate) => { + if (!current) return candidate; + const currentVisibleTop = Math.max(scrollAreaTop, current.rect.top); + const candidateVisibleTop = Math.max(scrollAreaTop, candidate.rect.top); + return candidateVisibleTop < currentVisibleTop ? candidate : current; + }, + null, + ); + return topMost + ? { + rowId: topMost.rowId, + offsetWithinRow: Math.max(0, scrollAreaTop - topMost.rect.top), + } + : null; } function findTimelineRowElement( @@ -243,12 +284,14 @@ export function BottomAnchoredScrollBody({ const shouldStickToBottomRef = useRef(true); const userScrollIntentUntilRef = useRef(0); const pointerScrollIntentRef = useRef(false); + const userScrollIntentGenerationRef = useRef(0); + // A request-scoped prepend may end geometrically near the bottom. Remember + // the user-intent generation that initiated it so its programmatic scroll + // event cannot immediately re-arm sticky-bottom. New user intent makes the + // generations differ and restores the normal near-bottom behavior. + const requestRestoreDetachedGenerationRef = useRef(null); const restoreFrameRef = useRef(null); const restoreFramesRemainingRef = useRef(0); - const pendingPrependAnchorRef = useRef<{ - scrollHeight: number; - scrollTop: number; - } | null>(null); // A non-bottom anchor being restored. It stays pending across ResizeObserver // settle frames because the mount layout pass can read stale row geometry // (rows hydrate after mount); re-applying converges on the right position. @@ -336,6 +379,7 @@ export function BottomAnchoredScrollBody({ userScrollIntentUntilRef.current = 0; pointerScrollIntentRef.current = false; userDetachedFromBottomRef.current = false; + requestRestoreDetachedGenerationRef.current = null; shouldStickToBottomRef.current = true; setIsAtBottom(true); if (scrollArea) { @@ -379,6 +423,7 @@ export function BottomAnchoredScrollBody({ setIsAtBottom(targetIsAtBottom); if (targetIsAtBottom) { + requestRestoreDetachedGenerationRef.current = null; queueBottomRestore(); return; } @@ -390,22 +435,40 @@ export function BottomAnchoredScrollBody({ const captureScrollAnchor = useCallback(() => { const scrollArea = scrollAreaRef.current; - if (!scrollArea) return; - pendingPrependAnchorRef.current = { - scrollHeight: scrollArea.scrollHeight, - scrollTop: scrollArea.scrollTop, + const visibleRow = scrollArea ? getTopMostVisibleRow(scrollArea) : null; + const userScrollIntentGeneration = userScrollIntentGenerationRef.current; + let active = true; + return { + cancel: () => { + active = false; + }, + restore: () => { + if ( + !active || + !scrollArea || + !visibleRow || + userScrollIntentGenerationRef.current !== userScrollIntentGeneration + ) { + return; + } + active = false; + const row = findTimelineRowElement(scrollArea, visibleRow.rowId); + if (!row) return; + // A successful request-scoped prepend intentionally detaches this view + // from the streaming bottom. Cancel any settle tail queued before the + // request committed so a later ResizeObserver pass cannot undo the + // row-relative correction. + shouldStickToBottomRef.current = false; + setIsAtBottom(false); + cancelQueuedRestore(); + requestRestoreDetachedGenerationRef.current = + userScrollIntentGeneration; + const scrollAreaTop = scrollArea.getBoundingClientRect().top; + const desiredRowTop = scrollAreaTop - visibleRow.offsetWithinRow; + scrollArea.scrollTop += row.getBoundingClientRect().top - desiredRowTop; + }, }; - }, []); - - useLayoutEffect(() => { - const scrollArea = scrollAreaRef.current; - const anchor = pendingPrependAnchorRef.current; - if (!scrollArea || !anchor) return; - const delta = scrollArea.scrollHeight - anchor.scrollHeight; - if (delta <= 0) return; - scrollArea.scrollTop = anchor.scrollTop + delta; - pendingPrependAnchorRef.current = null; - }); + }, [cancelQueuedRestore]); const hasRecentUserScrollIntent = useCallback(() => { return ( @@ -426,9 +489,12 @@ export function BottomAnchoredScrollBody({ if (!scrollArea) return; const atBottomByGeometry = isScrolledNearBottom(scrollArea); const recentUserIntent = hasRecentUserScrollIntent(); + const requestRestoreDetached = + requestRestoreDetachedGenerationRef.current === + userScrollIntentGenerationRef.current; const anchorAtom = threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId); - if (atBottomByGeometry) { + if (atBottomByGeometry && !requestRestoreDetached) { userDetachedFromBottomRef.current = false; store.set(anchorAtom, { rowId: "", @@ -513,12 +579,14 @@ export function BottomAnchoredScrollBody({ ); const markUserScrollIntent = useCallback(() => { + userScrollIntentGenerationRef.current += 1; userScrollIntentUntilRef.current = window.performance.now() + USER_SCROLL_INTENT_MS; }, []); const markWheelScrollIntent = useCallback( (event: WheelEvent) => { + userScrollIntentGenerationRef.current += 1; const scrollArea = scrollAreaRef.current; if (event.deltaY > 0 && scrollArea && isScrolledNearBottom(scrollArea)) { userScrollIntentUntilRef.current = 0; @@ -538,6 +606,7 @@ export function BottomAnchoredScrollBody({ }, [markUserScrollIntent]); const startPointerScrollIntent = useCallback(() => { + userScrollIntentGenerationRef.current += 1; pointerScrollIntentRef.current = true; }, []); @@ -563,6 +632,16 @@ export function BottomAnchoredScrollBody({ if (!scrollArea) return; if (isScrolledNearBottom(scrollArea)) { + if ( + requestRestoreDetachedGenerationRef.current === + userScrollIntentGenerationRef.current + ) { + shouldStickToBottomRef.current = false; + setIsAtBottom(false); + cancelQueuedRestore(); + return; + } + requestRestoreDetachedGenerationRef.current = null; userDetachedFromBottomRef.current = false; shouldStickToBottomRef.current = true; userScrollIntentUntilRef.current = 0; diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 9d59cc8a4a..c2a354ac59 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -162,13 +162,14 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "hostsQueryKey", "sidebarNavigationQueryKey", "systemConfigQueryKey", - "threadDefaultExecutionOptionsQueryKey", - "threadQueryKey", - "threadTabsQueryKey", - "threadSearchQueryKeyPrefix", + "threadDefaultExecutionOptionsQueryKey", + "threadQueryKey", + "threadSearchQueryKeyPrefix", "threadStorageFilePreviewQueryKeyPrefix", "threadStorageFilesForThreadQueryKeyPrefix", "threadStoragePathsForThreadQueryKeyPrefix", + "threadTabsQueryKey", + "threadTimelineTurnSummaryDetailsQueryKeyPrefix", "terminalsQueryKey", "threadsQueryKey", ], @@ -206,9 +207,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/system-config-cache-owner.ts": ["systemConfigQueryKey"], - "hooks/cache-owners/system-version-cache-owner.ts": [ - "systemVersionQueryKey", - ], + "hooks/cache-owners/system-version-cache-owner.ts": ["systemVersionQueryKey"], "hooks/cache-owners/terminal-cache-owner.ts": [ "allTerminalsQueryKeyPrefix", "TerminalQueryScope", @@ -249,6 +248,10 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadSearchQueryKeyPrefix", "threadsQueryKey", ], + "hooks/cache-owners/thread-timeline-cache-owner.ts": [ + "ThreadTimelineTurnSummaryDetailsQueryIdentity", + "threadTimelineTurnSummaryDetailsQueryKey", + ], }; function getSourceRoot(): string { diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 967c8e2a37..934d5b487c 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -85,6 +85,7 @@ import { threadStorageFilePreviewQueryKeyPrefix, threadStorageFilesForThreadQueryKeyPrefix, threadStoragePathsForThreadQueryKeyPrefix, + threadTimelineTurnSummaryDetailsQueryKeyPrefix, } from "../queries/query-keys"; import { allPluginContributionsQueryKeyPrefix } from "../queries/plugin-contribution-queries"; import { @@ -628,11 +629,24 @@ function dirtyThreadTimelineQueries({ queryClient, threadId, }: ThreadRealtimeDirtyContext): void { - // Window only: completed turn-summary-details are immutable, so realtime - // event batches must not refetch open detail panels (see helper docs). + const activeTurnDetailKeys = threadId + ? queryClient + .getQueryCache() + .findAll({ + queryKey: threadTimelineTurnSummaryDetailsQueryKeyPrefix(threadId), + }) + .flatMap((query) => + query.queryKey[5] === true ? [query.queryKey] : [], + ) + : []; + // Completed ranges remain immutable; only pending expansions follow the + // active turn and therefore join the window invalidation batch. invalidateQueryKeysWithoutCancelingActiveFetches({ queryClient, - queryKeys: getThreadTimelineWindowInvalidationQueryKeys({ threadId }), + queryKeys: [ + ...getThreadTimelineWindowInvalidationQueryKeys({ threadId }), + ...activeTurnDetailKeys, + ], }); } diff --git a/apps/app/src/hooks/cache-owners/thread-timeline-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-timeline-cache-owner.ts new file mode 100644 index 0000000000..f51dc7056a --- /dev/null +++ b/apps/app/src/hooks/cache-owners/thread-timeline-cache-owner.ts @@ -0,0 +1,15 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + threadTimelineTurnSummaryDetailsQueryKey, + type ThreadTimelineTurnSummaryDetailsQueryIdentity, +} from "../queries/query-keys"; + +export function invalidateActiveTurnSummaryDetails(args: { + identity: ThreadTimelineTurnSummaryDetailsQueryIdentity; + queryClient: QueryClient; +}): Promise { + return args.queryClient.invalidateQueries({ + exact: true, + queryKey: threadTimelineTurnSummaryDetailsQueryKey(args.identity), + }); +} diff --git a/apps/app/src/hooks/mutations/settings-mutations.test.tsx b/apps/app/src/hooks/mutations/settings-mutations.test.tsx index ec2b41b465..ba13cac870 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/settings-mutations.test.tsx @@ -77,6 +77,10 @@ describe("general settings mutation", () => { const configKey = systemConfigQueryKey(); const timelineKey = threadTimelineQueryKey("thread-1"); const summaryKey = threadTimelineTurnSummaryDetailsQueryKey({ + active: false, + contextItemIds: [], + detailKind: "turn", + parentToolCallId: null, threadId: "thread-1", turnId: "turn-1", sourceSeqStart: 1, diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index adf2a6e1b8..c2c84152ae 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -337,18 +337,37 @@ export type ThreadConversationOutlineQueryKeyPrefix = readonly [ export type AllThreadConversationOutlineQueryKeyPrefix = readonly [ typeof THREAD_CONVERSATION_OUTLINE_QUERY_KEY, ]; -export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { +interface ThreadTimelineTurnSummaryDetailsQueryIdentityBase { + active: boolean; sourceSeqEnd: number; sourceSeqStart: number; threadId: string; turnId: string; } +export type ThreadTimelineTurnSummaryDetailsQueryIdentity = + | (ThreadTimelineTurnSummaryDetailsQueryIdentityBase & { + contextItemIds: readonly string[]; + detailKind: "turn"; + parentToolCallId: string | null; + }) + | (ThreadTimelineTurnSummaryDetailsQueryIdentityBase & { + detailKind: "delegation-children"; + directTurnSourceSeqEnd: number; + directTurnSourceSeqStart: number; + parentToolCallId: string; + }); export type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, string, number, - number, + number | "active", + boolean, + readonly string[], + "turn" | "delegation-children", + string | null, + number | null, + number | "active" | null, ]; export type ThreadTimelineQueryKeyPrefix = readonly [ typeof THREAD_TIMELINE_QUERY_KEY, @@ -892,17 +911,35 @@ export function allThreadConversationOutlineQueryKeyPrefix(): AllThreadConversat } export function threadTimelineTurnSummaryDetailsQueryKey({ - sourceSeqEnd, - sourceSeqStart, - threadId, - turnId, + active, + ...identity }: ThreadTimelineTurnSummaryDetailsQueryIdentity): ThreadTimelineTurnSummaryDetailsQueryKey { + const { + detailKind, + parentToolCallId, + sourceSeqEnd, + sourceSeqStart, + threadId, + turnId, + } = identity; return [ THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, threadId, turnId, sourceSeqStart, - sourceSeqEnd, + active ? "active" : sourceSeqEnd, + active, + identity.detailKind === "turn" ? [...identity.contextItemIds].sort() : [], + detailKind, + parentToolCallId, + identity.detailKind === "delegation-children" + ? identity.directTurnSourceSeqStart + : null, + identity.detailKind === "delegation-children" + ? active + ? "active" + : identity.directTurnSourceSeqEnd + : null, ]; } diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index d8ab000d58..2a66086e43 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "@/lib/api"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; @@ -8,11 +8,13 @@ import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; import { threadHostFilePreviewQueryKey, threadQueuedMessagesQueryKey, + threadTimelineTurnSummaryDetailsQueryKey, } from "./query-keys"; import { useArchivedThreads, useThreadHostFilePreview, useThreadQueuedMessages, + useThreadTimelineTurnSummaryDetails, } from "./thread-queries"; vi.mock("@/lib/api", async (importOriginal) => { @@ -20,6 +22,7 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...actual, getThreadHostFilePreview: vi.fn(), + getThreadTimelineTurnSummaryDetails: vi.fn(), listThreadQueuedMessages: vi.fn(), listThreads: vi.fn(), }; @@ -45,6 +48,107 @@ beforeEach(() => { mimeType: "text/plain", content: "preview", }); + vi.mocked(api.getThreadTimelineTurnSummaryDetails).mockImplementation( + async ({ beforeCursor }) => ({ + rows: [], + timelinePage: { + hasOlderRows: beforeCursor === null, + olderCursor: + beforeCursor === null + ? { + anchorId: "timeline-event-window:event-40", + anchorSeq: 40, + } + : null, + }, + }), + ); +}); + +describe("useThreadTimelineTurnSummaryDetails", () => { + it("keeps distinct context-item sets in distinct detail caches", () => { + const identity = { + active: true, + detailKind: "turn", + parentToolCallId: null, + sourceSeqEnd: 100, + sourceSeqStart: 2, + threadId: "thread-1", + turnId: "turn-1", + } as const; + expect( + threadTimelineTurnSummaryDetailsQueryKey({ + ...identity, + contextItemIds: ["a", "b"], + }), + ).not.toEqual( + threadTimelineTurnSummaryDetailsQueryKey({ + ...identity, + contextItemIds: ["a\u0000b"], + }), + ); + }); + + it("keeps delegation child intervals in distinct detail caches", () => { + const identity = { + active: false, + detailKind: "delegation-children", + directTurnSourceSeqStart: 10, + parentToolCallId: "delegation-1", + sourceSeqEnd: 100, + sourceSeqStart: 1, + threadId: "thread-1", + turnId: "turn-1", + } as const; + expect( + threadTimelineTurnSummaryDetailsQueryKey({ + ...identity, + directTurnSourceSeqEnd: 20, + }), + ).not.toEqual( + threadTimelineTurnSummaryDetailsQueryKey({ + ...identity, + directTurnSourceSeqEnd: 30, + }), + ); + }); + + it("refetches loaded active pages when the summary frontier advances", async () => { + const { wrapper } = createQueryClientTestHarness(); + const { result, rerender } = renderHook( + ({ sourceSeqEnd }: { sourceSeqEnd: number }) => + useThreadTimelineTurnSummaryDetails({ + active: true, + contextItemIds: [], + detailKind: "turn", + parentToolCallId: null, + sourceSeqEnd, + sourceSeqStart: 2, + threadId: "thread-1", + turnId: "turn-1", + }), + { initialProps: { sourceSeqEnd: 100 }, wrapper }, + ); + await waitFor(() => expect(result.current.data?.pages).toHaveLength(1)); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + await act(async () => { + await result.current.fetchNextPage(); + }); + await waitFor(() => expect(result.current.data?.pages).toHaveLength(2)); + + vi.mocked(api.getThreadTimelineTurnSummaryDetails).mockClear(); + rerender({ sourceSeqEnd: 120 }); + expect(result.current.data?.pages).toHaveLength(2); + await waitFor(() => + expect( + vi + .mocked(api.getThreadTimelineTurnSummaryDetails) + .mock.calls.some(([args]) => args.sourceSeqEnd === 120), + ).toBe(true), + ); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data?.pages).toHaveLength(2); + }); }); describe("useArchivedThreads", () => { diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index e115fafdd7..73dff60d42 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -4,7 +4,7 @@ import { useQueryClient, type QueryClient, } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useDebounceValue } from "usehooks-ts"; import type { PendingInteraction, ThreadWithRuntime } from "@bb/domain"; import type { @@ -20,6 +20,7 @@ import type { ThreadStoragePathListResponse, ThreadTimelineResponse, TimelineTurnSummaryDetailsResponse, + TimelinePaginationCursor, } from "@bb/server-contract"; import { applyTimelineDelta } from "@bb/server-contract"; import type { ThreadListFilters, FilePreview } from "@/lib/api"; @@ -76,6 +77,7 @@ import { } from "./query-keys"; import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; import { ingestThreadDetailBootstrap } from "../cache-owners/thread-detail-cache-owner"; +import { invalidateActiveTurnSummaryDetails } from "../cache-owners/thread-timeline-cache-owner"; interface QueryOptions { enabled?: boolean; @@ -811,10 +813,18 @@ export function useThreadTimelineTurnSummaryDetails( identity: ThreadTimelineTurnSummaryDetailsQueryIdentity, options?: ThreadTimelineTurnSummaryDetailsQueryOptions, ) { - return useQuery({ - queryKey: threadTimelineTurnSummaryDetailsQueryKey(identity), - queryFn: ({ signal }) => - api.getThreadTimelineTurnSummaryDetails({ + const queryClient = useQueryClient(); + const queryKey = threadTimelineTurnSummaryDetailsQueryKey(identity); + const serializedQueryKey = JSON.stringify(queryKey); + const previousActiveFrontierRef = useRef({ + queryKey: serializedQueryKey, + sourceSeqEnd: identity.sourceSeqEnd, + }); + const query = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }) => { + const base = { + beforeCursor: pageParam, id: requireThreadId( identity.threadId, "useThreadTimelineTurnSummaryDetails", @@ -823,7 +833,25 @@ export function useThreadTimelineTurnSummaryDetails( sourceSeqEnd: identity.sourceSeqEnd, sourceSeqStart: identity.sourceSeqStart, turnId: identity.turnId, - }), + }; + return identity.detailKind === "delegation-children" + ? api.getThreadTimelineTurnSummaryDetails({ + ...base, + detailKind: identity.detailKind, + directTurnSourceSeqEnd: identity.directTurnSourceSeqEnd, + directTurnSourceSeqStart: identity.directTurnSourceSeqStart, + parentToolCallId: identity.parentToolCallId, + }) + : api.getThreadTimelineTurnSummaryDetails({ + ...base, + contextItemIds: [...identity.contextItemIds], + detailKind: identity.detailKind, + parentToolCallId: identity.parentToolCallId, + }); + }, + initialPageParam: null as TimelinePaginationCursor | null, + getNextPageParam: (lastPage: TimelineTurnSummaryDetailsResponse) => + lastPage.timelinePage.olderCursor ?? undefined, enabled: (options?.enabled ?? true) && Boolean(identity.threadId) && @@ -835,6 +863,35 @@ export function useThreadTimelineTurnSummaryDetails( refetchOnMount: options?.refetchOnMount ?? true, staleTime: options?.staleTime ?? Infinity, }); + + useEffect(() => { + const previous = previousActiveFrontierRef.current; + previousActiveFrontierRef.current = { + queryKey: serializedQueryKey, + sourceSeqEnd: identity.sourceSeqEnd, + }; + if ( + !identity.active || + previous.queryKey !== serializedQueryKey || + previous.sourceSeqEnd === identity.sourceSeqEnd + ) { + return; + } + + // Realtime can invalidate the stable active key before the refreshed + // timeline supplies its new range end. Invalidate again after that end is + // rendered so every loaded page is rebuilt from page one with cursors + // signed for the new immutable snapshot. + void invalidateActiveTurnSummaryDetails({ identity, queryClient }); + }, [ + identity, + identity.active, + identity.sourceSeqEnd, + queryClient, + serializedQueryKey, + ]); + + return query; } export function getLatestPendingInteraction( diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 939f67c8a3..45f34dbbd6 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -216,6 +216,10 @@ describe("createRealtimeCacheEffects", () => { const configKey = systemConfigQueryKey(); const timelineKey = threadTimelineQueryKey("thr_1"); const summaryKey = threadTimelineTurnSummaryDetailsQueryKey({ + active: false, + contextItemIds: [], + detailKind: "turn", + parentToolCallId: null, threadId: "thr_1", turnId: "turn_1", sourceSeqStart: 1, @@ -937,8 +941,8 @@ describe("createRealtimeCacheEffects", () => { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, @@ -968,20 +972,35 @@ describe("createRealtimeCacheEffects", () => { // A completed turn's expanded detail panel is immutable; events-appended // must not refetch it (W2). const turnDetailsKey = threadTimelineTurnSummaryDetailsQueryKey({ + active: false, + contextItemIds: [], + detailKind: "turn", + parentToolCallId: null, threadId: "thr_1", turnId: "turn_1", sourceSeqStart: 1, sourceSeqEnd: 5, }); + const activeTurnDetailsKey = threadTimelineTurnSummaryDetailsQueryKey({ + active: true, + contextItemIds: [], + detailKind: "turn", + parentToolCallId: null, + threadId: "thr_1", + turnId: "turn_2", + sourceSeqStart: 6, + sourceSeqEnd: 10, + }); queryClient.setQueryData(threadKey, { id: "thr_1" }); queryClient.setQueryData(promptHistoryKey, []); queryClient.setQueryData(turnDetailsKey, { rows: [] }); + queryClient.setQueryData(activeTurnDetailsKey, { rows: [] }); queryClient.setQueryData(timelineKey, { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, @@ -1004,6 +1023,9 @@ describe("createRealtimeCacheEffects", () => { expect(queryClient.getQueryState(turnDetailsKey)?.isInvalidated).not.toBe( true, ); + expect(queryClient.getQueryState(activeTurnDetailsKey)?.isInvalidated).toBe( + true, + ); effects.dispose(); }); @@ -1053,8 +1075,8 @@ describe("createRealtimeCacheEffects", () => { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, @@ -1067,8 +1089,8 @@ describe("createRealtimeCacheEffects", () => { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, @@ -1207,8 +1229,8 @@ describe("createRealtimeCacheEffects", () => { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, @@ -1274,8 +1296,8 @@ describe("createRealtimeCacheEffects", () => { rows: [], timelinePage: { kind: "latest", - topLevelLimit: 100, - returnedOlderTopLevelRowCount: 0, + segmentLimit: 100, + returnedSegmentCount: 0, hasOlderRows: false, olderCursor: null, }, diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index 06454c3c9d..bcdf4e1361 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -129,10 +129,11 @@ interface GetThreadTimelineArgs { signal?: AbortSignal; } -interface GetThreadTimelineTurnSummaryDetailsArgs extends TimelineTurnSummaryDetailsRequest { - id: string; - signal?: AbortSignal; -} +type GetThreadTimelineTurnSummaryDetailsArgs = + TimelineTurnSummaryDetailsRequest & { + id: string; + signal?: AbortSignal; + }; interface GetEnvironmentFilePreviewArgs { id: string; @@ -1650,22 +1651,46 @@ export async function getThreadConversationOutline({ ); } -export async function getThreadTimelineTurnSummaryDetails({ - id, - signal, - turnId, - sourceSeqStart, - sourceSeqEnd, -}: GetThreadTimelineTurnSummaryDetailsArgs): Promise { +export async function getThreadTimelineTurnSummaryDetails( + args: GetThreadTimelineTurnSummaryDetailsArgs, +): Promise { + const { beforeCursor, id, signal, turnId, sourceSeqStart, sourceSeqEnd } = + args; + const cursorQuery = beforeCursor + ? { + beforeAnchorSeq: String(beforeCursor.anchorSeq), + beforeAnchorId: beforeCursor.anchorId, + } + : {}; + const selectionQuery = { + turnId, + sourceSeqStart: String(sourceSeqStart), + sourceSeqEnd: String(sourceSeqEnd), + }; + const query = + args.detailKind === "delegation-children" + ? { + ...selectionQuery, + ...cursorQuery, + detailKind: args.detailKind, + directTurnSourceSeqEnd: String(args.directTurnSourceSeqEnd), + directTurnSourceSeqStart: String(args.directTurnSourceSeqStart), + parentToolCallId: args.parentToolCallId, + } + : { + ...selectionQuery, + ...cursorQuery, + detailKind: args.detailKind, + contextItemIds: JSON.stringify(args.contextItemIds), + ...(args.parentToolCallId === null + ? {} + : { parentToolCallId: args.parentToolCallId }), + }; return request( apiClient.threads[":id"].timeline["turn-summary-details"].$get( { param: { id }, - query: { - turnId, - sourceSeqStart: String(sourceSeqStart), - sourceSeqEnd: String(sourceSeqEnd), - }, + query, }, requestOptions(signal), ), diff --git a/apps/app/src/lib/side-chat-create-request.test.ts b/apps/app/src/lib/side-chat-create-request.test.ts index 6ccb028dcc..25d3e74010 100644 --- a/apps/app/src/lib/side-chat-create-request.test.ts +++ b/apps/app/src/lib/side-chat-create-request.test.ts @@ -102,6 +102,8 @@ describe("resolveSideChatReplyReference", () => { startedAt: 1, createdAt: 1, kind: "turn", + detailContextItemIds: [], + detailParentToolCallId: null, status: "completed", summaryCount: 0, completedAt: 9, @@ -315,7 +317,9 @@ describe("buildSideChatMessageInput", () => { const input = buildSideChatMessageInput({ includeReplyReference: true, replyReference: "An earlier message worth discussing.", - visibleInput: [{ type: "text", text: "Why this approach?", mentions: [] }], + visibleInput: [ + { type: "text", text: "Why this approach?", mentions: [] }, + ], }); expect(input).toHaveLength(2); diff --git a/apps/app/src/test/fixtures/thread-timeline-rows.ts b/apps/app/src/test/fixtures/thread-timeline-rows.ts index d88494455b..ba030091bd 100644 --- a/apps/app/src/test/fixtures/thread-timeline-rows.ts +++ b/apps/app/src/test/fixtures/thread-timeline-rows.ts @@ -225,11 +225,10 @@ export interface SystemRowArgs extends RowBaseOverrideArgs { turnId?: string | null; } -export interface NonOperationSystemRowArgs - extends Omit< - SystemRowArgs, - "completedAt" | "durationMs" | "parentChange" | "operationKind" | "systemKind" - > { +export interface NonOperationSystemRowArgs extends Omit< + SystemRowArgs, + "completedAt" | "durationMs" | "parentChange" | "operationKind" | "systemKind" +> { systemKind: TimelineNonOperationSystemRow["systemKind"]; } @@ -258,6 +257,7 @@ export interface DelegationRowArgs extends RowBaseOverrideArgs { export interface TurnRowArgs extends RowBaseOverrideArgs { children?: TimelineRow[] | null; + detailContextItemIds?: string[]; durationMs?: number | null; id?: string; seq?: number; @@ -1073,12 +1073,14 @@ export function delegationRow({ description, output, completedAt: completedAtFromDuration(base.startedAt, durationMs), + childPage: null, childRows, }; } export function turnRow({ children = null, + detailContextItemIds = [], createdAt, durationMs = 4_000, id = DEFAULT_TURN_ROW_ID, @@ -1105,6 +1107,8 @@ export function turnRow({ ...base, kind: "turn", turnId, + detailContextItemIds, + detailParentToolCallId: null, status, summaryCount, completedAt: completedAtFromDuration(base.startedAt, durationMs), diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 6ac9b5f5c2..ac0c4ac9c9 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -749,6 +749,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { isLoadingOlderTimelineRows, loadOlderTimelineRows, modelFallback, + paginationSurfaceKey, pendingTodos, timelineError, timelineLoading, @@ -2593,6 +2594,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { ? handleSelectionReplyInSideChat : undefined, onLoadOlderRows: loadOlderTimelineRows, + paginationSurfaceKey, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, onOpenPluginPanel: handleOpenTimelinePluginPanel, diff --git a/apps/app/src/views/thread-detail/ThreadTimelinePane.test.tsx b/apps/app/src/views/thread-detail/ThreadTimelinePane.test.tsx index a3e8e098ad..673d254d57 100644 --- a/apps/app/src/views/thread-detail/ThreadTimelinePane.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadTimelinePane.test.tsx @@ -30,7 +30,7 @@ it("forwards the plugin-panel opener to rendered message directives", () => { isLoadingOlderTimelineRows={false} isStopping={false} isThreadTimelinePending={false} - onLoadOlderRows={() => undefined} + onLoadOlderRows={() => false} onOpenPluginPanel={() => true} projectId="proj_1" resolveMentionLink={() => null} diff --git a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx index 91fcf7b413..c8047c7b06 100644 --- a/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx +++ b/apps/app/src/views/thread-detail/ThreadTimelinePane.tsx @@ -15,7 +15,7 @@ interface ThreadTimelinePaneProps extends ThreadTimelineSurfaceProps { hasOlderTimelineRows: boolean; isLoadingOlderTimelineRows: boolean; isStopping: boolean; - onLoadOlderRows: () => void; + onLoadOlderRows: () => Promise | boolean; resolveMentionLink: PromptMentionLinkResolver; stoppingAnchorAt: number; unreadDividerAutoScroll: boolean; @@ -44,6 +44,7 @@ export function ThreadTimelinePane({ onOpenLocalFileLink, onOpenPluginPanel, onTitleAction, + paginationSurfaceKey, projectId, resolveMentionLink, showOngoingIndicator, @@ -98,6 +99,7 @@ export function ThreadTimelinePane({ onOpenLocalFileLink={onOpenLocalFileLink} onOpenPluginPanel={onOpenPluginPanel} onTitleAction={onTitleAction} + paginationSurfaceKey={paginationSurfaceKey} projectId={projectId} resolveMentionLink={resolveMentionLink} showOngoingIndicator={showOngoingIndicator} diff --git a/apps/app/src/views/thread-detail/useThreadTimelinePages.test.ts b/apps/app/src/views/thread-detail/useThreadTimelinePages.test.ts index a4a6dbd310..0c2ef5ba69 100644 --- a/apps/app/src/views/thread-detail/useThreadTimelinePages.test.ts +++ b/apps/app/src/views/thread-detail/useThreadTimelinePages.test.ts @@ -83,6 +83,8 @@ function turnSummaryRow(args: TimelineTestRowArgs): TimelineTurnRow { startedAt: args.sequence, createdAt: args.sequence, kind: "turn", + detailContextItemIds: [], + detailParentToolCallId: null, status: "completed", summaryCount: 1, completedAt: args.sequence, diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index f488bb3896..390fdda888 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -13,6 +13,7 @@ import type { Hono } from "hono"; import { PROMPT_HISTORY_ENTRY_LIMIT, threadEventTypeSchema } from "@bb/domain"; import { publicApiRoutes, + timelineTurnDetailsContextItemIdsSchema, typedRoutes, type PublicApiSchema, type ThreadComposerBootstrapResponse, @@ -46,6 +47,7 @@ import { toThreadQueuedMessage } from "../../services/threads/thread-queued-mess import { buildThreadConversationOutline, buildThreadTimeline, + buildTimelineDelegationChildrenDetails, buildTimelineTurnSummaryDetails, THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT, THREAD_TIMELINE_SEGMENT_LIMIT_MAX, @@ -56,6 +58,7 @@ import { buildThreadTimelineCacheKey, buildThreadTimelineParamsKey, createThreadTimelineCache, + isThreadTimelineResponseCacheable, } from "../../services/threads/timeline-cache.js"; import { createTimelineLatestRowsCache } from "../../services/threads/timeline-latest-rows-cache.js"; import { truncateTimelineResponseOutputs } from "../../services/threads/timeline-output-truncation.js"; @@ -400,6 +403,7 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { summaryOnly, }), ), + { cacheable: isThreadTimelineResponseCacheable(thread.status) }, ); // Delta: when the client tells us the revision it currently holds and our @@ -458,19 +462,71 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { get(routes.timelineTurnSummaryDetails, (context, query) => { const thread = requirePublicThread(deps.db, context.req.param("id")); + const beforeCursor = + query.beforeAnchorSeq === undefined || query.beforeAnchorId === undefined + ? null + : { + anchorSeq: parseInteger(query.beforeAnchorSeq, "beforeAnchorSeq"), + anchorId: query.beforeAnchorId, + }; + const selection = { + beforeCursor, + turnId: query.turnId, + sourceSeqStart: parseInteger(query.sourceSeqStart, "sourceSeqStart"), + sourceSeqEnd: parseInteger(query.sourceSeqEnd, "sourceSeqEnd"), + }; + if (query.detailKind === "delegation-children") { + return context.json( + buildTimelineDelegationChildrenDetails(deps.db, thread, { + ...selection, + directTurnSourceSeqEnd: parseInteger( + query.directTurnSourceSeqEnd, + "directTurnSourceSeqEnd", + ), + directTurnSourceSeqStart: parseInteger( + query.directTurnSourceSeqStart, + "directTurnSourceSeqStart", + ), + parentToolCallId: query.parentToolCallId, + }), + ); + } + let contextItemIds: string[] = []; + if (query.contextItemIds !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(query.contextItemIds); + } catch { + throw new ApiError( + 400, + "invalid_request", + "contextItemIds must be a JSON string array", + ); + } + const parsedContextItemIds = + timelineTurnDetailsContextItemIdsSchema.safeParse(parsed); + if (!parsedContextItemIds.success) { + throw new ApiError( + 400, + "invalid_request", + "contextItemIds must be a JSON string array", + ); + } + contextItemIds = parsedContextItemIds.data; + } const includeProviderUnhandledOperations = deps.config.isDevelopment || getAppSettings(deps.db).showUnhandledProviderEvents; return context.json( buildTimelineTurnSummaryDetails(deps.db, thread, { + ...selection, + contextItemIds, includeProviderUnhandledOperations, + parentToolCallId: query.parentToolCallId ?? null, providerDisplayName: resolveThreadProviderDisplayName( deps, thread.providerId, ), - turnId: query.turnId, - sourceSeqStart: parseInteger(query.sourceSeqStart, "sourceSeqStart"), - sourceSeqEnd: parseInteger(query.sourceSeqEnd, "sourceSeqEnd"), }), ); }); diff --git a/apps/server/src/services/threads/timeline-active-work-window.ts b/apps/server/src/services/threads/timeline-active-work-window.ts new file mode 100644 index 0000000000..4b49672fee --- /dev/null +++ b/apps/server/src/services/threads/timeline-active-work-window.ts @@ -0,0 +1,249 @@ +import type { ThreadStatus } from "@bb/domain"; +import type { TimelineRow, TimelineTurnRow } from "@bb/server-contract"; + +export const ACTIVE_TIMELINE_TAIL_ROW_TARGET = 80; +export const ACTIVE_TIMELINE_TAIL_JSON_BYTE_TARGET = 256_000; +export const ACTIVE_TIMELINE_PINNED_PENDING_ROW_LIMIT = 16; +export const ACTIVE_TIMELINE_PINNED_PENDING_JSON_BYTE_TARGET = 128_000; + +function isMessageAnchor(row: TimelineRow): boolean { + return ( + row.kind === "conversation" && + row.role === "user" && + row.turnRequest.kind === "message" + ); +} + +function rowIsPending(row: TimelineRow): boolean { + return row.kind !== "conversation" && row.status === "pending"; +} + +function isRunningThread(status: ThreadStatus): boolean { + return status === "starting" || status === "active" || status === "stopping"; +} + +function activeSummaryRow( + anchor: TimelineRow, + turnId: string, + omittedRows: readonly TimelineRow[], + sourceSeqStartOverride?: number, +): TimelineTurnRow | null { + const first = omittedRows[0]; + const last = omittedRows.at(-1); + if (!first || !last) { + return null; + } + const sourceSeqStart = sourceSeqStartOverride ?? first.sourceSeqStart; + return { + id: `${anchor.threadId}:${turnId}:active-turn:${sourceSeqStart}`, + threadId: anchor.threadId, + turnId, + detailContextItemIds: [], + detailParentToolCallId: null, + sourceSeqStart, + sourceSeqEnd: omittedRows.reduce( + (maximum, row) => Math.max(maximum, row.sourceSeqEnd), + last.sourceSeqEnd, + ), + startedAt: first.startedAt, + createdAt: last.createdAt, + kind: "turn", + status: "pending", + summaryCount: omittedRows.length, + completedAt: null, + children: null, + }; +} + +/** + * Keeps the active turn's prompt and mutable frontier visible while replacing + * an arbitrarily large finalized prefix with the same lazy turn row used by + * completed "Worked for …" summaries. This runs after full projection, so it + * changes delivery shape without changing event-replay semantics. + */ +export function collapseActiveTimelineWork(args: { + olderEventSequence?: number; + rows: readonly TimelineRow[]; + threadStatus: ThreadStatus; +}): TimelineRow[] { + const { olderEventSequence, rows, threadStatus } = args; + if (!isRunningThread(threadStatus)) { + return [...rows]; + } + + let anchorIndex = -1; + for (let index = rows.length - 1; index >= 0; index--) { + const row = rows[index]; + if (row && isMessageAnchor(row)) { + anchorIndex = index; + break; + } + } + const anchor = rows[anchorIndex]; + if (!anchor) { + return [...rows]; + } + + const segment = rows.slice(anchorIndex + 1); + if (segment.some((row) => row.kind === "turn")) { + return [...rows]; + } + const activeTurnId = segment.find((row) => row.turnId !== null)?.turnId; + if (!activeTurnId) { + return [...rows]; + } + const coversOlderEventWindow = + olderEventSequence !== undefined && + olderEventSequence > anchor.sourceSeqEnd; + + let tailStart = segment.length; + let tailBytes = 0; + while (tailStart > 0) { + const candidate = segment[tailStart - 1]; + if (!candidate) break; + const selectedCount = segment.length - tailStart; + const candidateBytes = Buffer.byteLength(JSON.stringify(candidate), "utf8"); + if ( + selectedCount >= ACTIVE_TIMELINE_TAIL_ROW_TARGET || + (selectedCount > 0 && + tailBytes + candidateBytes > ACTIVE_TIMELINE_TAIL_JSON_BYTE_TARGET) + ) { + break; + } + tailBytes += candidateBytes; + tailStart--; + } + + // Keep a bounded set of older mutable rows visible without pulling every + // finalized row after the oldest one back into the live window. Contiguous + // omitted runs become independent lazy summaries, which preserves source + // order and gives every gap an exact expansion range. + const pinnedPendingIndexes = new Set(); + let pinnedPendingBytes = 0; + for (let index = tailStart - 1; index >= 0; index--) { + const row = segment[index]; + if (!row || !rowIsPending(row)) { + continue; + } + if (pinnedPendingIndexes.size >= ACTIVE_TIMELINE_PINNED_PENDING_ROW_LIMIT) { + break; + } + const rowBytes = Buffer.byteLength(JSON.stringify(row), "utf8"); + if ( + pinnedPendingBytes + rowBytes > + ACTIVE_TIMELINE_PINNED_PENDING_JSON_BYTE_TARGET + ) { + continue; + } + pinnedPendingIndexes.add(index); + pinnedPendingBytes += rowBytes; + } + + if (tailStart === 0 && !coversOlderEventWindow) { + return [...rows]; + } + + const collapsedRows: TimelineRow[] = [...rows.slice(0, anchorIndex + 1)]; + const historicalEnd = + olderEventSequence === undefined ? null : olderEventSequence - 1; + const historicalPinnedIndexes = new Set( + [...pinnedPendingIndexes].filter((index) => { + const row = segment[index]; + return ( + historicalEnd !== null && + row !== undefined && + row.sourceSeqStart <= historicalEnd + ); + }), + ); + const combineLeadingGapWithFirstOmittedRun = + coversOlderEventWindow && + tailStart > 0 && + !pinnedPendingIndexes.has(0) && + historicalPinnedIndexes.size === 0; + if ( + coversOlderEventWindow && + !combineLeadingGapWithFirstOmittedRun && + historicalEnd !== null && + historicalEnd >= anchor.sourceSeqEnd + 1 + ) { + const template = segment[0]; + if (template) { + let gapStart = anchor.sourceSeqEnd + 1; + for (const index of [...historicalPinnedIndexes].sort( + (left, right) => + (segment[left]?.sourceSeqStart ?? 0) - + (segment[right]?.sourceSeqStart ?? 0), + )) { + const pinned = segment[index]; + if (!pinned) { + continue; + } + if (gapStart < pinned.sourceSeqStart) { + const gap = activeSummaryRow(anchor, activeTurnId, [ + { + ...template, + sourceSeqStart: gapStart, + sourceSeqEnd: Math.min(historicalEnd, pinned.sourceSeqStart - 1), + }, + ]); + if (gap) { + collapsedRows.push(gap); + } + } + collapsedRows.push(pinned); + gapStart = Math.max(gapStart, pinned.sourceSeqEnd + 1); + } + if (gapStart <= historicalEnd) { + const gap = activeSummaryRow(anchor, activeTurnId, [ + { + ...template, + sourceSeqStart: gapStart, + sourceSeqEnd: historicalEnd, + }, + ]); + if (gap) { + collapsedRows.push(gap); + } + } + } + } + let omittedRun: TimelineRow[] = []; + let isFirstOmittedRun = true; + const flushOmittedRun = (): void => { + const summary = activeSummaryRow( + anchor, + activeTurnId, + omittedRun, + isFirstOmittedRun && combineLeadingGapWithFirstOmittedRun + ? anchor.sourceSeqEnd + 1 + : undefined, + ); + if (summary) { + collapsedRows.push(summary); + isFirstOmittedRun = false; + } + omittedRun = []; + }; + + for (let index = 0; index < segment.length; index++) { + const row = segment[index]; + if (!row) { + continue; + } + if ( + historicalPinnedIndexes.has(index) || + (historicalEnd !== null && row.sourceSeqEnd <= historicalEnd) + ) { + continue; + } + if (index >= tailStart || pinnedPendingIndexes.has(index)) { + flushOmittedRun(); + collapsedRows.push(row); + continue; + } + omittedRun.push(row); + } + flushOmittedRun(); + return collapsedRows; +} diff --git a/apps/server/src/services/threads/timeline-cache.ts b/apps/server/src/services/threads/timeline-cache.ts index 9533a0b4a5..f7b6116a93 100644 --- a/apps/server/src/services/threads/timeline-cache.ts +++ b/apps/server/src/services/threads/timeline-cache.ts @@ -22,11 +22,10 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js"; * (`pruneResolvedItemDeltas`, background-task progress) is output-preserving * and never lowers `maxSeq`, so it cannot stale a cached entry. * - * Entries with many rows are not cached: an expanded active turn (the streaming - * case) produces hundreds of rows AND a `maxSeq` that changes on every event, - * so caching it only thrashes the LRU and pins large objects for no reuse. Idle - * windows collapse completed turns to a handful of rows regardless of thread - * size, so the cap excludes exactly the entries that would never be reused. + * Actively running thread revisions are never cached: their `maxSeq` changes on + * every event, so each entry is immediately obsolete. This remains explicit + * even when active-window pagination keeps their row count small. The row cap + * separately protects idle responses containing unusually large expansions. */ const DEFAULT_MAX_ENTRIES = 128; @@ -42,6 +41,7 @@ export interface ThreadTimelineCache { getOrBuild( key: string, build: () => ThreadTimelineResponse, + options?: { cacheable?: boolean }, ): ThreadTimelineResponse; /** Number of currently cached entries (for tests/metrics). */ readonly size: number; @@ -56,8 +56,9 @@ export function createThreadTimelineCache( const entries = new Map(); return { - getOrBuild(key, build) { - const cached = entries.get(key); + getOrBuild(key, build, buildOptions = {}) { + const cacheable = buildOptions.cacheable !== false; + const cached = cacheable ? entries.get(key) : undefined; if (cached !== undefined) { // Re-insert to mark most-recently-used. entries.delete(key); @@ -66,7 +67,7 @@ export function createThreadTimelineCache( } const value = build(); - if (value.rows.length <= maxCacheableRows) { + if (cacheable && value.rows.length <= maxCacheableRows) { entries.set(key, value); while (entries.size > maxEntries) { const oldest = entries.keys().next().value; @@ -84,6 +85,12 @@ export function createThreadTimelineCache( }; } +export function isThreadTimelineResponseCacheable( + status: ThreadStatus, +): boolean { + return status === "idle" || status === "error"; +} + export interface ThreadTimelineCacheKeyArgs { threadId: string; /** Thread high-water event sequence; bumps on every appended event. */ diff --git a/apps/server/src/services/threads/timeline-pagination.ts b/apps/server/src/services/threads/timeline-pagination.ts index b2f3af58d1..d1e1309d4a 100644 --- a/apps/server/src/services/threads/timeline-pagination.ts +++ b/apps/server/src/services/threads/timeline-pagination.ts @@ -1,8 +1,14 @@ +import { + createHash, + createHmac, + randomBytes, + timingSafeEqual, +} from "node:crypto"; import type { TimelinePaginationCursor, TimelineRow, } from "@bb/server-contract"; -import { ApiError } from "../../errors.js"; +import { z } from "zod"; export type ThreadTimelinePageKind = "latest" | "older"; @@ -26,6 +32,75 @@ interface TimelineLogicalSegment { rows: TimelineRow[]; } +const TIMELINE_EVENT_WINDOW_CURSOR_PREFIX = "timeline-event-window:"; +const TIMELINE_EVENT_WINDOW_CURSOR_VERSION = 2; +const timelineEventWindowCursorSigningKey = randomBytes(32); + +const timelineEventWindowCursorScopeSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("timeline"), + segmentLimit: z.number().int().positive(), + threadId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("turn-details"), + contextItemIdsHash: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + parentToolCallId: z.string().min(1).nullable(), + sourceSeqEnd: z.number().int().nonnegative(), + sourceSeqStart: z.number().int().nonnegative(), + threadId: z.string().min(1), + turnId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("delegation-children"), + directTurnSourceSeqEnd: z.number().int().nonnegative(), + directTurnSourceSeqStart: z.number().int().nonnegative(), + ownerTurnId: z.string().min(1), + parentToolCallId: z.string().min(1), + sourceSeqEnd: z.number().int().nonnegative(), + sourceSeqStart: z.number().int().nonnegative(), + threadId: z.string().min(1), + }) + .strict(), +]); + +export type TimelineEventWindowCursorScope = z.infer< + typeof timelineEventWindowCursorScopeSchema +>; + +const timelineEventWindowCursorPayloadSchema = z + .object({ + byteTarget: z.number().int().positive(), + eventId: z.string().min(1), + issuedBeforeSequence: z.number().int().positive().nullable(), + rowLimit: z.number().int().positive(), + scope: timelineEventWindowCursorScopeSchema, + selectionStart: z.number().int().nonnegative(), + version: z.literal(TIMELINE_EVENT_WINDOW_CURSOR_VERSION), + }) + .strict(); + +export type TimelineEventWindowCursorPayload = z.infer< + typeof timelineEventWindowCursorPayloadSchema +>; + +export function hashTimelineTurnDetailsContextItemIds( + contextItemIds: readonly string[], +): string { + const canonicalContextItemIds = [...new Set(contextItemIds)].sort(); + return createHash("sha256") + .update(JSON.stringify(canonicalContextItemIds), "utf8") + .digest("base64url"); +} + +/** Latest active delivery remains below this target after active-work collapse. */ +export const THREAD_TIMELINE_PAGE_ROW_LIMIT = 160; + export interface PaginatedTimelineRowsResult { hasOlderRows: boolean; kind: ThreadTimelinePageKind; @@ -35,6 +110,11 @@ export interface PaginatedTimelineRowsResult { segmentLimit: number; } +export interface PaginateTimelineRowsOptions { + /** Older raw events exist before the projected event window. */ + eventWindowOlderCursor: TimelinePaginationCursor | null; +} + function isTimelineSegmentAnchorRow(row: TimelineRow): boolean { return ( row.kind === "conversation" && @@ -87,51 +167,106 @@ function buildTimelineLogicalSegments( return segments; } -function requireTimelineSegmentCursorIndex( - segments: readonly TimelineLogicalSegment[], +function signTimelineEventWindowCursorPayload(encodedPayload: string): string { + return createHmac("sha256", timelineEventWindowCursorSigningKey) + .update(encodedPayload) + .digest("base64url"); +} + +export function getTimelineEventWindowCursorPayload( cursor: TimelinePaginationCursor, -): number { - const index = segments.findIndex( - (segment) => - segment.cursor.anchorSeq === cursor.anchorSeq && - segment.cursor.anchorId === cursor.anchorId, +): TimelineEventWindowCursorPayload | null { + if (!cursor.anchorId.startsWith(TIMELINE_EVENT_WINDOW_CURSOR_PREFIX)) { + return null; + } + const encodedCursor = cursor.anchorId.slice( + TIMELINE_EVENT_WINDOW_CURSOR_PREFIX.length, ); - if (index !== -1) { - return index; + const separatorIndex = encodedCursor.lastIndexOf("."); + if (separatorIndex <= 0 || separatorIndex === encodedCursor.length - 1) { + return null; + } + const encodedPayload = encodedCursor.slice(0, separatorIndex); + const presentedSignature = encodedCursor.slice(separatorIndex + 1); + const expectedSignature = + signTimelineEventWindowCursorPayload(encodedPayload); + const presentedBytes = Buffer.from(presentedSignature, "utf8"); + const expectedBytes = Buffer.from(expectedSignature, "utf8"); + if ( + presentedBytes.length !== expectedBytes.length || + !timingSafeEqual(presentedBytes, expectedBytes) + ) { + return null; + } + try { + const parsedJson = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8"), + ); + const parsedPayload = + timelineEventWindowCursorPayloadSchema.safeParse(parsedJson); + if (!parsedPayload.success) return null; + return parsedPayload.data; + } catch { + return null; } +} - throw new ApiError( - 400, - "invalid_request", - "Timeline pagination cursor is no longer available", +export function createTimelineEventWindowCursor(args: { + byteTarget: number; + eventId: string; + issuedBeforeSequence: number | null; + rowLimit: number; + scope: TimelineEventWindowCursorScope; + selectionStart: number; + sequence: number; +}): TimelinePaginationCursor { + const payload: TimelineEventWindowCursorPayload = { + byteTarget: args.byteTarget, + eventId: args.eventId, + issuedBeforeSequence: args.issuedBeforeSequence, + rowLimit: args.rowLimit, + scope: args.scope, + selectionStart: args.selectionStart, + version: TIMELINE_EVENT_WINDOW_CURSOR_VERSION, + }; + const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString( + "base64url", ); + const signature = signTimelineEventWindowCursorPayload(encodedPayload); + return { + anchorSeq: args.sequence, + anchorId: `${TIMELINE_EVENT_WINDOW_CURSOR_PREFIX}${encodedPayload}.${signature}`, + }; } export function paginateTimelineRows( rows: readonly TimelineRow[], page: ThreadTimelinePageRequest, + options: PaginateTimelineRowsOptions, ): PaginatedTimelineRowsResult { const segments = buildTimelineLogicalSegments(rows); - const candidateSegments = - page.kind === "latest" - ? segments - : segments.slice( - 0, - requireTimelineSegmentCursorIndex(segments, page.beforeCursor), - ); + // SQL selection has already applied both raw-event and conversation-segment + // cursors. Never apply the cursor again to enriched/projected rows. + const candidateSegments = segments; const selectedSegments = candidateSegments.slice(-page.segmentLimit); - const hasOlderRows = candidateSegments.length > selectedSegments.length; + const selectedRows = selectedSegments.flatMap((segment) => segment.rows); + const hasOlderSegments = candidateSegments.length > selectedSegments.length; + const hasOlderRows = + hasOlderSegments || options.eventWindowOlderCursor !== null; const oldestSelectedSegment = selectedSegments[0]; return { hasOlderRows, kind: page.kind, - olderCursor: - hasOlderRows && oldestSelectedSegment - ? oldestSelectedSegment.cursor - : null, + olderCursor: !hasOlderRows + ? null + : selectedRows.length === 0 + ? options.eventWindowOlderCursor + : hasOlderSegments + ? (oldestSelectedSegment?.cursor ?? null) + : options.eventWindowOlderCursor, returnedSegmentCount: selectedSegments.length, - rows: selectedSegments.flatMap((segment) => segment.rows), + rows: selectedRows, segmentLimit: page.segmentLimit, }; } diff --git a/apps/server/src/services/threads/timeline-turn-details-pagination.ts b/apps/server/src/services/threads/timeline-turn-details-pagination.ts new file mode 100644 index 0000000000..63f0e39ae6 --- /dev/null +++ b/apps/server/src/services/threads/timeline-turn-details-pagination.ts @@ -0,0 +1,33 @@ +import type { + TimelinePaginationCursor, + TimelineRow, +} from "@bb/server-contract"; + +export interface PaginatedTimelineTurnDetails { + hasOlderRows: boolean; + olderCursor: TimelinePaginationCursor | null; + rows: TimelineRow[]; +} + +export interface PaginateTimelineTurnDetailsOptions { + /** Continuation owned by the exact bounded raw-event window. */ + eventWindowOlderCursor: TimelinePaginationCursor | null; +} + +/** + * Raw event selection is the sole resource and continuation boundary. A + * projected-row cursor is unsafe because lifecycle/anchor enrichment can give + * a visible row a source start outside the exact raw window and skip the raw + * events in between. Projection may amplify a bounded raw window, but it can + * no longer create an independent pagination boundary. + */ +export function paginateTimelineTurnDetails( + rows: readonly TimelineRow[], + options: PaginateTimelineTurnDetailsOptions, +): PaginatedTimelineTurnDetails { + return { + hasOlderRows: options.eventWindowOlderCursor !== null, + olderCursor: options.eventWindowOlderCursor, + rows: [...rows], + }; +} diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index 9dcbad0be1..6d9e57bf30 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -6,41 +6,62 @@ import { type AcceptedClientRequestContext, type ThreadEventWithMeta, } from "@bb/thread-view"; -import type { ClientTurnRequestId, Thread } from "@bb/domain"; +import type { ClientTurnRequestId, Thread, ThreadEventType } from "@bb/domain"; import type { ThreadConversationOutlineItem, ThreadConversationOutlineResponse, TimelineConversationAttachments, + TimelineDelegationChildInterval, TimelinePaginationCursor, + TimelineRow, ThreadConversationOutlineAttachmentSummary, ThreadTimelineResponse, TimelineTurnSummaryDetailsResponse, } from "@bb/server-contract"; import { - findTimelineSegmentAnchorSequenceAfter, getEnvironment, + getStoredEventIdentityAtSequence, getTimelineSegmentAnchorAtSequence, + hasStoredTurnEventInRange, listContextWindowUsageRows, - listRecentStoredEventRows, + listLatestGoalEventRowsForThread, listStoredClientTurnRequestIdsInRange, listStoredEventRowsByParentToolCallIds, - listStoredEventRowsInRange, + listStoredDelegatedTurnDescendantRanges, + listStoredDelegationChildTurnRanges, + listStoredDelegationDescendantRanges, + listStoredNonemptyDelegationChildTurnBucketIndexes, + listStoredTurnDescendantRanges, + listStoredItemLifecycleOwnerSequences, + listStoredItemStartedRowsByItemIds, + listStoredConversationOutlineEventRows, listLatestBackgroundTaskStateRowsByItemIds, listLatestOpenBackgroundTaskStateRowsForThread, - listStoredTimelineWindowEventRows, + listStoredTimelineWindowEventRowsDescending, listStoredToolCallRowsByItemIds, listStoredTurnInputAcceptedRowsByClientRequestIds, listStoredTurnStartedRowsByTurnIdsUpToSequence, listTimelineSegmentAnchorsDescending, } from "@bb/db"; -import type { DbConnection, StoredEventRow } from "@bb/db"; +import type { + BoundedStoredEventRowsResult, + DbConnection, + StoredEventIdentity, + StoredEventRow, +} from "@bb/db"; import { ApiError } from "../../errors.js"; import { parseStoredEvent } from "./thread-data.js"; import { + createTimelineEventWindowCursor, + getTimelineEventWindowCursorPayload, + hashTimelineTurnDetailsContextItemIds, paginateTimelineRows, + type TimelineEventWindowCursorScope, type ThreadTimelinePageKind, type ThreadTimelinePageRequest, } from "./timeline-pagination.js"; +import { paginateTimelineTurnDetails } from "./timeline-turn-details-pagination.js"; +import { collapseActiveTimelineWork } from "./timeline-active-work-window.js"; export type { LatestThreadTimelinePageRequest, @@ -115,12 +136,23 @@ interface BuildThreadTimelineOptions { } interface BuildTimelineTurnSummaryDetailsOptions extends TimelineTurnSummarySelection { + beforeCursor: TimelinePaginationCursor | null; + contextItemIds: readonly string[]; includeProviderUnhandledOperations: boolean; + parentToolCallId?: string | null; providerDisplayName?: string; } +interface BuildTimelineDelegationChildrenDetailsOptions extends TimelineTurnSummarySelection { + beforeCursor: TimelinePaginationCursor | null; + directTurnSourceSeqEnd: number; + directTurnSourceSeqStart: number; + parentToolCallId: string; +} + export const THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT = 20; export const THREAD_TIMELINE_SEGMENT_LIMIT_MAX = 100; +export const THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT = 50; export type ThreadTimelineBuildProfileStage = | "event-query" @@ -145,6 +177,8 @@ export interface ThreadTimelineBuildProfile { contextWindowEventDataBytes: number; contextWindowEventRowCount: number; decodedEventCount: number; + enrichmentEventBytes: number; + enrichmentEventRowCount: number; eventDataBytes: number; eventRowCount: number; pageKind: ThreadTimelinePageKind; @@ -167,6 +201,8 @@ interface ThreadTimelineBuildProfileAccumulator { contextWindowEventDataBytes: number; contextWindowEventRowCount: number; decodedEventCount: number; + enrichmentEventBytes: number; + enrichmentEventRowCount: number; eventDataBytes: number; eventRowCount: number; projectedRowCount: number; @@ -184,15 +220,26 @@ interface BuildThreadTimelineInternalOptions extends BuildThreadTimelineOptions interface TimelineEventRowSelection { acceptedClientRequestContextRows: StoredEventRow[]; contextOnlyToolCallIds: Set; + exactEventSequenceEnd: number; + exactEventSequenceStart: number; paginationPage: ThreadTimelinePageRequest; + eventWindowOlderCursor: TimelinePaginationCursor | null; + enrichmentBudget: TimelineParentedEnrichmentBudget; + lifecycleOwnerSequenceEnd: number; + lifecycleOwnerSequenceStart: number; responsePageKind: ThreadTimelinePageKind; rows: StoredEventRow[]; strategy: ThreadTimelineEventSelectionStrategy; } interface TimelineWindowRowsArgs { + budget?: TimelineParentedEnrichmentBudget; includeParentContext?: boolean; + /** Restrict descendant expansion to lifecycle roots owned by this page. */ + parentedRootToolCallIds?: ReadonlySet; rows: readonly StoredEventRow[]; + sequenceEnd?: number; + sequenceStart?: number; threadId: string; } @@ -202,6 +249,8 @@ interface TimelineWindowParentedRowsResult { } interface SelectAcceptedClientRequestContextRowsArgs { + budget: TimelineParentedEnrichmentBudget; + excludedRowIds?: readonly string[]; rows: readonly StoredEventRow[]; threadId: string; } @@ -421,13 +470,65 @@ function collectStoredParentToolCallIds( return [...parentToolCallIds]; } +export const THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT = 100; +export const THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET = 256_000; +const THREAD_TIMELINE_PARENTED_LIFECYCLE_ROW_RESERVE = 16; +const THREAD_TIMELINE_PARENTED_LIFECYCLE_BYTE_RESERVE = 32_000; + +interface TimelineParentedEnrichmentBudget { + remainingBytes: number; + remainingRows: number; +} + +function consumeTimelineEnrichmentResult( + result: BoundedStoredEventRowsResult, + budget: TimelineParentedEnrichmentBudget, +): StoredEventRow[] { + if ( + result.rows.length > budget.remainingRows || + result.dataBytes > budget.remainingBytes + ) { + throw new Error("Bounded stored event query exceeded its requested budget"); + } + budget.remainingRows -= result.rows.length; + budget.remainingBytes -= result.dataBytes; + return result.rows; +} + function ensureTimelineWindowParentedRows( db: DbConnection, args: TimelineWindowRowsArgs, ): TimelineWindowParentedRowsResult { let rows = [...args.rows]; const rowIds = new Set(rows.map((row) => row.id)); - const visibleToolCallIds = new Set(collectStoredToolCallItemIds(rows)); + const enrichmentBudget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + const childBudget: TimelineParentedEnrichmentBudget = { + remainingBytes: Math.max( + 0, + enrichmentBudget.remainingBytes - + THREAD_TIMELINE_PARENTED_LIFECYCLE_BYTE_RESERVE, + ), + remainingRows: Math.max( + 0, + enrichmentBudget.remainingRows - + THREAD_TIMELINE_PARENTED_LIFECYCLE_ROW_RESERVE, + ), + }; + const initialChildRows = childBudget.remainingRows; + const initialChildBytes = childBudget.remainingBytes; + const visibleToolCallIds = new Set( + collectStoredToolCallItemIds(rows).filter( + (itemId) => + args.parentedRootToolCallIds === undefined || + args.parentedRootToolCallIds.has(itemId), + ), + ); + const fetchedChildToolCallIds = new Set(); while (true) { @@ -441,12 +542,24 @@ function ensureTimelineWindowParentedRows( fetchedChildToolCallIds.add(toolCallId); } - const childRows = listStoredEventRowsByParentToolCallIds(db, { + if (childBudget.remainingRows === 0 || childBudget.remainingBytes === 0) { + continue; + } + + const childResult = listStoredEventRowsByParentToolCallIds(db, { + excludedRowIds: [...rowIds], excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + limit: childBudget.remainingRows, + maxBytes: childBudget.remainingBytes, parentToolCallIds: toolCallIdsToFetch, + sequenceEnd: args.sequenceEnd, + sequenceStart: args.sequenceStart, threadId: args.threadId, }); - const newChildRows = childRows.filter((row) => !rowIds.has(row.id)); + const newChildRows = consumeTimelineEnrichmentResult( + childResult, + childBudget, + ); if (newChildRows.length === 0) { continue; } @@ -459,6 +572,22 @@ function ensureTimelineWindowParentedRows( rows = mergeStoredEventRowsById([...rows, ...newChildRows]); } + enrichmentBudget.remainingRows -= + initialChildRows - childBudget.remainingRows; + enrichmentBudget.remainingBytes -= + initialChildBytes - childBudget.remainingBytes; + + // A newest-first descendant window can contain only output deltas for a + // still-running child. Spend the reserved part of this same enrichment + // budget on the child's lifecycle root so projection does not buffer those + // deltas forever and drop the command from the timeline. + rows = ensureTimelineWindowItemStartedRows(db, { + budget: enrichmentBudget, + rows, + sequenceEnd: args.sequenceEnd, + threadId: args.threadId, + }); + if (args.includeParentContext === false) { return { contextOnlyToolCallIds: new Set(), @@ -467,26 +596,47 @@ function ensureTimelineWindowParentedRows( } const contextOnlyToolCallIds = new Set(); - const missingParentToolCallIds = collectStoredParentToolCallIds(rows).filter( - (parentToolCallId) => !visibleToolCallIds.has(parentToolCallId), - ); - const parentRows = listStoredToolCallRowsByItemIds(db, { - itemIds: missingParentToolCallIds, - threadId: args.threadId, - }); - const newParentRows = parentRows.filter((row) => !rowIds.has(row.id)); - for (const row of parentRows) { - if (row.itemId !== null && !visibleToolCallIds.has(row.itemId)) { - contextOnlyToolCallIds.add(row.itemId); + const fetchedParentToolCallIds = new Set(); + while ( + enrichmentBudget.remainingRows > 0 && + enrichmentBudget.remainingBytes > 0 + ) { + const missingParentToolCallIds = collectStoredParentToolCallIds( + rows, + ).filter( + (parentToolCallId) => + !visibleToolCallIds.has(parentToolCallId) && + !fetchedParentToolCallIds.has(parentToolCallId), + ); + if (missingParentToolCallIds.length === 0) break; + for (const parentToolCallId of missingParentToolCallIds) { + fetchedParentToolCallIds.add(parentToolCallId); } + const parentResult = listStoredToolCallRowsByItemIds(db, { + excludedRowIds: [...rowIds], + itemIds: missingParentToolCallIds, + limit: enrichmentBudget.remainingRows, + maxBytes: enrichmentBudget.remainingBytes, + sequenceEnd: args.sequenceEnd, + threadId: args.threadId, + }); + const newParentRows = consumeTimelineEnrichmentResult( + parentResult, + enrichmentBudget, + ); + if (newParentRows.length === 0) break; + for (const row of newParentRows) { + rowIds.add(row.id); + if (row.itemId !== null && !visibleToolCallIds.has(row.itemId)) { + contextOnlyToolCallIds.add(row.itemId); + } + } + rows = mergeStoredEventRowsById([...newParentRows, ...rows]); } return { contextOnlyToolCallIds, - rows: - newParentRows.length > 0 - ? mergeStoredEventRowsById([...newParentRows, ...rows]) - : rows, + rows, }; } @@ -501,11 +651,21 @@ function selectAcceptedClientRequestContextRows( return []; } - return listStoredTurnInputAcceptedRowsByClientRequestIds(db, { + if (args.budget.remainingRows === 0 || args.budget.remainingBytes === 0) { + return []; + } + const result = listStoredTurnInputAcceptedRowsByClientRequestIds(db, { afterSequence: maxStoredEventSequence(args.rows), clientRequestIds, + excludedRowIds: [ + ...args.rows.map((row) => row.id), + ...(args.excludedRowIds ?? []), + ], + limit: args.budget.remainingRows, + maxBytes: args.budget.remainingBytes, threadId: args.threadId, }); + return consumeTimelineEnrichmentResult(result, args.budget); } function partitionAcceptedInputRowsByRequestedTurn( @@ -580,24 +740,6 @@ function resolveTurnSummaryDetailsSourceRange( }; } -function selectFullTimelineEventRows( - db: DbConnection, - thread: Thread, - page: ThreadTimelinePageRequest, -): TimelineEventRowSelection { - return { - acceptedClientRequestContextRows: [], - contextOnlyToolCallIds: new Set(), - paginationPage: page, - responsePageKind: page.kind, - rows: listRecentStoredEventRows(db, { - threadId: thread.id, - excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, - }), - strategy: "full", - }; -} - function collectTurnIdsMissingStartedRows( rows: readonly StoredEventRow[], ): string[] { @@ -638,11 +780,27 @@ function ensureTimelineWindowTurnStartedRows( return [...args.rows]; } - const turnStartedRows = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const turnStartedResult = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + excludedRowIds: args.rows.map((row) => row.id), + limit: budget.remainingRows, + maxBytes: budget.remainingBytes, threadId: args.threadId, sequenceCutoff: maxStoredEventSequence(args.rows), turnIds: missingTurnIds, }); + const turnStartedRows = consumeTimelineEnrichmentResult( + turnStartedResult, + budget, + ); if (turnStartedRows.length === 0) { return [...args.rows]; } @@ -650,6 +808,54 @@ function ensureTimelineWindowTurnStartedRows( return mergeStoredEventRowsById([...turnStartedRows, ...args.rows]); } +function ensureTimelineWindowItemStartedRows( + db: DbConnection, + args: TimelineWindowRowsArgs, +): StoredEventRow[] { + const startedItemIds = new Set(); + const referencedItemIds = new Set(); + for (const row of args.rows) { + if (row.itemId === null) { + continue; + } + if (row.type === "item/started") { + startedItemIds.add(row.itemId); + continue; + } + if (row.type !== "item/completed") { + referencedItemIds.add(row.itemId); + } + } + const missingItemIds = [...referencedItemIds].filter( + (itemId) => !startedItemIds.has(itemId), + ); + if (missingItemIds.length === 0) { + return [...args.rows]; + } + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const startedResult = listStoredItemStartedRowsByItemIds(db, { + excludedRowIds: args.rows.map((row) => row.id), + itemIds: missingItemIds, + limit: budget.remainingRows, + maxBytes: budget.remainingBytes, + sequenceCutoff: args.sequenceEnd, + threadId: args.threadId, + }); + const selectedStartedRows = consumeTimelineEnrichmentResult( + startedResult, + budget, + ); + return mergeStoredEventRowsById([...selectedStartedRows, ...args.rows]); +} + /** * Background tasks outlive their spawning turn: a window containing an * in-flight task's item/started may end long before the task's thread-scoped @@ -671,10 +877,26 @@ function ensureTimelineWindowBackgroundTaskStateRows( return [...args.rows]; } - const stateRows = listLatestBackgroundTaskStateRowsByItemIds(db, { - threadId: args.threadId, + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const stateResult = listLatestBackgroundTaskStateRowsByItemIds(db, { + excludedRowIds: args.rows.map((row) => row.id), itemIds: [...itemIds], + limit: Math.min(itemIds.size, budget.remainingRows), + maxBytes: budget.remainingBytes, + maxDataBytes: + THREAD_TIMELINE_OPEN_BACKGROUND_TASK_STATE_MAX_DATA_BYTES_PER_ROW, + sequenceCutoff: args.sequenceEnd, + threadId: args.threadId, }); + const stateRows = consumeTimelineEnrichmentResult(stateResult, budget); if (stateRows.length === 0) { return [...args.rows]; } @@ -686,9 +908,27 @@ function ensureLatestTimelineOpenBackgroundTaskStateRows( db: DbConnection, args: TimelineWindowRowsArgs, ): StoredEventRow[] { - const stateRows = listLatestOpenBackgroundTaskStateRowsForThread(db, { + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const stateResult = listLatestOpenBackgroundTaskStateRowsForThread(db, { + excludedRowIds: args.rows.map((row) => row.id), + limit: Math.min( + THREAD_TIMELINE_OPEN_BACKGROUND_TASK_STATE_ROW_LIMIT, + budget.remainingRows, + ), + maxBytes: budget.remainingBytes, + maxDataBytes: + THREAD_TIMELINE_OPEN_BACKGROUND_TASK_STATE_MAX_DATA_BYTES_PER_ROW, threadId: args.threadId, }); + const stateRows = consumeTimelineEnrichmentResult(stateResult, budget); if (stateRows.length === 0) { return [...args.rows]; } @@ -696,6 +936,186 @@ function ensureLatestTimelineOpenBackgroundTaskStateRows( return mergeStoredEventRowsById([...args.rows, ...stateRows]); } +function ensureLatestTimelineGoalStateRow( + db: DbConnection, + args: TimelineWindowRowsArgs, +): StoredEventRow[] { + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const goalResult = listLatestGoalEventRowsForThread(db, { + excludedRowIds: args.rows.map((row) => row.id), + limit: 1, + maxBytes: budget.remainingBytes, + threadId: args.threadId, + }); + const goalRows = consumeTimelineEnrichmentResult(goalResult, budget); + return mergeStoredEventRowsById([...args.rows, ...goalRows]); +} + +/** + * A bounded event suffix can begin in the middle of a turn. Restore the + * nearest message anchor and its accepted-input link so projection can still + * associate the visible work with the initiating user request. This is a + * targeted lookup: it adds one anchor and at most the matching acceptance + * rows, never the discarded event prefix. + */ +function ensureTimelineWindowSegmentAnchorContextRows( + db: DbConnection, + args: TimelineWindowRowsArgs, +): StoredEventRow[] { + const firstRow = args.rows[0]; + if (!firstRow) { + return []; + } + const anchor = listTimelineSegmentAnchorsDescending(db, { + beforeSequence: firstRow.sequence + 1, + limit: 1, + threadId: args.threadId, + })[0]; + if (!anchor || args.rows.some((row) => row.sequence === anchor.sequence)) { + return [...args.rows]; + } + + const budget = + args.budget ?? + ({ + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + } satisfies TimelineParentedEnrichmentBudget); + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return [...args.rows]; + } + const anchorResult = listStoredTimelineWindowEventRowsDescending(db, { + beforeSequence: anchor.sequence + 1, + excludedRowIds: args.rows.map((row) => row.id), + excludedTypes: [], + limit: 1, + maxBytes: budget.remainingBytes, + sequenceStart: anchor.sequence, + threadId: args.threadId, + }); + const anchorRows = consumeTimelineEnrichmentResult(anchorResult, budget); + const clientRequestIds = anchorRows.flatMap((row) => { + const requestId = tryReadClientTurnRequestedRequestId(row); + return requestId === null ? [] : [requestId]; + }); + if ( + clientRequestIds.length === 0 || + budget.remainingRows === 0 || + budget.remainingBytes === 0 + ) { + return mergeStoredEventRowsById([...anchorRows, ...args.rows]); + } + const acceptedResult = listStoredTurnInputAcceptedRowsByClientRequestIds(db, { + afterSequence: anchor.sequence, + clientRequestIds, + excludedRowIds: [...args.rows, ...anchorRows].map((row) => row.id), + limit: budget.remainingRows, + maxBytes: budget.remainingBytes, + threadId: args.threadId, + }); + const acceptedRows = consumeTimelineEnrichmentResult(acceptedResult, budget); + return mergeStoredEventRowsById([ + ...anchorRows, + ...acceptedRows, + ...args.rows, + ]); +} + +export const THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT = 400; +export const THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET = 750_000; +export const THREAD_TIMELINE_OPEN_BACKGROUND_TASK_STATE_ROW_LIMIT = 16; +export const THREAD_TIMELINE_OPEN_BACKGROUND_TASK_STATE_MAX_DATA_BYTES_PER_ROW = 16_000; +const THREAD_TIMELINE_EVENT_WINDOW_EXCLUDED_EVENT_TYPES = [ + ...THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + // Goal state has its own targeted latest-row query below and never produces + // timeline rows. Repeated goal updates must not evict visible work from a + // bounded event window. + "thread/goal/updated", + "thread/goal/cleared", +] satisfies readonly ThreadEventType[]; + +interface BoundedTimelineEventRows { + olderCursor: TimelinePaginationCursor | null; + rows: StoredEventRow[]; +} + +function replaceOversizedTimelineEventWithPlaceholder( + row: StoredEventRow, +): StoredEventRow { + const data = JSON.stringify({ + code: "timeline_event_payload_too_large", + message: `A ${row.type} event (${Buffer.byteLength(row.data, "utf8")} bytes) was too large to render inline. The stored event was retained.`, + }); + return { + ...row, + data, + itemId: null, + itemKind: null, + type: "system/error", + }; +} + +function boundTimelineEventRowsForProjection( + rows: readonly StoredEventRow[], +): StoredEventRow[] { + return rows.map((row) => + Buffer.byteLength(row.data, "utf8") <= + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET + ? row + : replaceOversizedTimelineEventWithPlaceholder(row), + ); +} + +function selectBoundedTimelineEventRows( + db: DbConnection, + args: { + beforeSequence: number | undefined; + cursorScope: TimelineEventWindowCursorScope; + sequenceStart: number; + threadId: string; + }, +): BoundedTimelineEventRows { + const result = listStoredTimelineWindowEventRowsDescending(db, { + beforeSequence: args.beforeSequence, + excludedTypes: THREAD_TIMELINE_EVENT_WINDOW_EXCLUDED_EVENT_TYPES, + limit: THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT, + maxBytes: THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + sequenceStart: args.sequenceStart, + threadId: args.threadId, + }); + if ( + result.rows.length > THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT || + result.dataBytes > THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET + ) { + throw new Error("Timeline event query exceeded its requested budget"); + } + const selectedDescending = result.rows; + const earliestSelected = selectedDescending.at(-1); + return { + olderCursor: + result.hasMore && earliestSelected + ? createTimelineEventWindowCursor({ + byteTarget: THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + eventId: earliestSelected.id, + issuedBeforeSequence: args.beforeSequence ?? null, + rowLimit: THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT, + scope: args.cursorScope, + selectionStart: args.sequenceStart, + sequence: earliestSelected.sequence, + }) + : null, + rows: selectedDescending.reverse(), + }; +} + interface ResolveTimelineSegmentWindowArgs { page: ThreadTimelinePageRequest; threadId: string; @@ -707,6 +1127,78 @@ interface ResolvedTimelineSegmentWindow { sequenceStart: number; } +function requireStoredTimelineEventWindowCursor( + db: DbConnection, + args: { + cursor: TimelinePaginationCursor; + byteTarget?: number; + errorMessage: string; + expectedScope: TimelineEventWindowCursorScope; + rowLimit?: number; + threadId: string; + }, +): StoredEventIdentity { + const payload = getTimelineEventWindowCursorPayload(args.cursor); + if ( + payload === null || + payload.byteTarget !== + (args.byteTarget ?? THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET) || + payload.rowLimit !== + (args.rowLimit ?? THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT) || + !areTimelineEventWindowCursorScopesEqual( + payload.scope, + args.expectedScope, + ) || + args.cursor.anchorSeq < payload.selectionStart || + (payload.issuedBeforeSequence !== null && + args.cursor.anchorSeq >= payload.issuedBeforeSequence) + ) { + throw new ApiError(400, "invalid_request", args.errorMessage); + } + const cursorEvent = getStoredEventIdentityAtSequence(db, { + sequence: args.cursor.anchorSeq, + threadId: args.threadId, + }); + if (!cursorEvent || cursorEvent.id !== payload.eventId) { + throw new ApiError(400, "invalid_request", args.errorMessage); + } + return cursorEvent; +} + +function areTimelineEventWindowCursorScopesEqual( + left: TimelineEventWindowCursorScope, + right: TimelineEventWindowCursorScope, +): boolean { + if (left.kind !== right.kind) return false; + if (left.threadId !== right.threadId) return false; + if (left.kind === "timeline" && right.kind === "timeline") { + return left.segmentLimit === right.segmentLimit; + } + if (left.kind === "turn-details" && right.kind === "turn-details") { + return ( + left.turnId === right.turnId && + left.contextItemIdsHash === right.contextItemIdsHash && + left.parentToolCallId === right.parentToolCallId && + left.sourceSeqStart === right.sourceSeqStart && + left.sourceSeqEnd === right.sourceSeqEnd + ); + } + if ( + left.kind === "delegation-children" && + right.kind === "delegation-children" + ) { + return ( + left.ownerTurnId === right.ownerTurnId && + left.parentToolCallId === right.parentToolCallId && + left.sourceSeqStart === right.sourceSeqStart && + left.sourceSeqEnd === right.sourceSeqEnd && + left.directTurnSourceSeqStart === right.directTurnSourceSeqStart && + left.directTurnSourceSeqEnd === right.directTurnSourceSeqEnd + ); + } + return false; +} + /** * Resolves the event-sequence window for a timeline page from segment anchors, * touching only the ~`segmentLimit` anchors around the page rather than every @@ -727,6 +1219,31 @@ function resolveTimelineSegmentWindow( if (page.kind === "older") { const cursor = page.beforeCursor; + const eventWindowPayload = getTimelineEventWindowCursorPayload(cursor); + if (eventWindowPayload !== null) { + requireStoredTimelineEventWindowCursor(db, { + cursor, + errorMessage: "Timeline pagination cursor is no longer available", + expectedScope: { + kind: "timeline", + segmentLimit: page.segmentLimit, + threadId, + }, + threadId, + }); + const precedingAnchors = listTimelineSegmentAnchorsDescending(db, { + beforeSequence: cursor.anchorSeq, + limit: page.segmentLimit + 1, + threadId, + }); + return { + beforeSequence: cursor.anchorSeq, + // A row-window cursor is itself proof that the thread has timeline + // content, including legacy threads with no message anchor. + hasAnchors: true, + sequenceStart: precedingAnchors[page.segmentLimit]?.sequence ?? 0, + }; + } const cursorAnchor = getTimelineSegmentAnchorAtSequence(db, { sequence: cursor.anchorSeq, threadId, @@ -751,10 +1268,7 @@ function resolveTimelineSegmentWindow( threadId, }); return { - beforeSequence: findTimelineSegmentAnchorSequenceAfter(db, { - sequence: cursor.anchorSeq, - threadId, - }), + beforeSequence: cursor.anchorSeq, hasAnchors: true, // The (segmentLimit + 1)-th anchor before the cursor is the window's // lower bound; fewer than that means the window reaches the thread start. @@ -780,36 +1294,60 @@ function selectStandardTimelineEventRows( db: DbConnection, thread: Thread, page: ThreadTimelinePageRequest, + maxSeq: number, ): TimelineEventRowSelection { const window = resolveTimelineSegmentWindow(db, { page, threadId: thread.id, }); - if (!window.hasAnchors) { - return selectFullTimelineEventRows(db, thread, page); - } - const beforeSequence = window.beforeSequence; const sequenceStart = window.sequenceStart; + const boundedEventRows = selectBoundedTimelineEventRows(db, { + beforeSequence, + cursorScope: { + kind: "timeline", + segmentLimit: page.segmentLimit, + threadId: thread.id, + }, + sequenceStart, + threadId: thread.id, + }); + const firstExactEventRow = boundedEventRows.rows[0]; + const lastExactEventRow = boundedEventRows.rows.at(-1); + const enrichmentBudget: TimelineParentedEnrichmentBudget = { + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + }; + const selectedRowsWithInWindowTaskState = ensureTimelineWindowBackgroundTaskStateRows(db, { + budget: enrichmentBudget, threadId: thread.id, rows: ensureTimelineWindowTurnStartedRows(db, { + budget: enrichmentBudget, threadId: thread.id, - rows: listStoredTimelineWindowEventRows(db, { - beforeSequence, - excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, - sequenceStart, + rows: ensureTimelineWindowItemStartedRows(db, { + budget: enrichmentBudget, threadId: thread.id, + rows: ensureTimelineWindowSegmentAnchorContextRows(db, { + budget: enrichmentBudget, + threadId: thread.id, + rows: boundedEventRows.rows, + }), }), }), }); const selectedRows = page.kind === "latest" - ? ensureLatestTimelineOpenBackgroundTaskStateRows(db, { + ? ensureLatestTimelineGoalStateRow(db, { + budget: enrichmentBudget, threadId: thread.id, - rows: selectedRowsWithInWindowTaskState, + rows: ensureLatestTimelineOpenBackgroundTaskStateRows(db, { + budget: enrichmentBudget, + threadId: thread.id, + rows: selectedRowsWithInWindowTaskState, + }), }) : selectedRowsWithInWindowTaskState; const selectedRowsWithContext = @@ -822,20 +1360,32 @@ function selectStandardTimelineEventRows( contextRows: [], rows: selectedRows, }; + const boundedSelectedRowsWithContext = { + contextRows: boundTimelineEventRowsForProjection( + selectedRowsWithContext.contextRows, + ), + rows: boundTimelineEventRowsForProjection(selectedRowsWithContext.rows), + }; const selectedRowsWithParentedContext = ensureTimelineWindowParentedRows(db, { + budget: enrichmentBudget, threadId: thread.id, - rows: selectedRowsWithContext.rows, + rows: boundedSelectedRowsWithContext.rows, }); const selectedRowsWithParentedTurnStarts = ensureTimelineWindowTurnStartedRows(db, { + budget: enrichmentBudget, threadId: thread.id, rows: selectedRowsWithParentedContext.rows, }); return { - acceptedClientRequestContextRows: selectedRowsWithContext.contextRows, + acceptedClientRequestContextRows: boundTimelineEventRowsForProjection( + boundedSelectedRowsWithContext.contextRows, + ), contextOnlyToolCallIds: selectedRowsWithParentedContext.contextOnlyToolCallIds, + exactEventSequenceEnd: lastExactEventRow?.sequence ?? 0, + exactEventSequenceStart: firstExactEventRow?.sequence ?? 0, paginationPage: page.kind === "older" ? page @@ -843,10 +1393,18 @@ function selectStandardTimelineEventRows( kind: "latest", segmentLimit: page.segmentLimit, }, + eventWindowOlderCursor: boundedEventRows.olderCursor, + enrichmentBudget, + lifecycleOwnerSequenceEnd: maxSeq, + lifecycleOwnerSequenceStart: 0, responsePageKind: page.kind, - rows: selectedRowsWithParentedTurnStarts, + rows: boundTimelineEventRowsForProjection( + selectedRowsWithParentedTurnStarts, + ), strategy: - sequenceStart === 0 && beforeSequence === undefined + boundedEventRows.olderCursor === null && + sequenceStart === 0 && + beforeSequence === undefined ? "full" : "standard-window", }; @@ -857,7 +1415,12 @@ function selectTimelineEventRows( thread: Thread, options: BuildThreadTimelineOptions, ): TimelineEventRowSelection { - return selectStandardTimelineEventRows(db, thread, options.page); + return selectStandardTimelineEventRows( + db, + thread, + options.page, + options.maxSeq, + ); } function byteLengthOfStoredEventRows(rows: readonly StoredEventRow[]): number { @@ -874,6 +1437,8 @@ function createThreadTimelineBuildProfileAccumulator(): ThreadTimelineBuildProfi contextWindowEventDataBytes: 0, contextWindowEventRowCount: 0, decodedEventCount: 0, + enrichmentEventBytes: 0, + enrichmentEventRowCount: 0, eventDataBytes: 0, eventRowCount: 0, projectedRowCount: 0, @@ -903,6 +1468,93 @@ function measureThreadTimelineStage( return result; } +function isTimelineMessageAnchor( + row: TimelineRow, +): row is Extract { + return ( + row.kind === "conversation" && + row.role === "user" && + row.turnRequest.kind === "message" + ); +} + +function isActivelyRunningThread(thread: Thread): boolean { + return ( + thread.status === "starting" || + thread.status === "active" || + thread.status === "stopping" + ); +} + +function prepareLatestTimelineDelivery(args: { + db: DbConnection; + eventWindowOlderCursor: TimelinePaginationCursor | null; + rows: readonly TimelineRow[]; + thread: Thread; +}): { + eventWindowOlderCursor: TimelinePaginationCursor | null; + rows: TimelineRow[]; +} { + const { db, eventWindowOlderCursor, rows, thread } = args; + let activeAnchor: + | Extract + | undefined; + for (let index = rows.length - 1; index >= 0; index--) { + const row = rows[index]; + if (row && isTimelineMessageAnchor(row)) { + activeAnchor = row; + break; + } + } + const cursorFallsInsideActiveTurn = + isActivelyRunningThread(thread) && + activeAnchor !== undefined && + eventWindowOlderCursor !== null && + eventWindowOlderCursor.anchorSeq > activeAnchor.sourceSeqEnd; + const collapsedRows = collapseActiveTimelineWork({ + ...(cursorFallsInsideActiveTurn + ? { olderEventSequence: eventWindowOlderCursor.anchorSeq } + : {}), + rows, + threadStatus: thread.status, + }); + if (!cursorFallsInsideActiveTurn || !activeAnchor) { + return { eventWindowOlderCursor, rows: collapsedRows }; + } + + // Work before the live tail is now reachable through the active turn's lazy + // summary. Keep top-level pagination only when an older conversation exists, + // and place that cursor immediately before the active prompt. + const hasEarlierConversation = + listTimelineSegmentAnchorsDescending(db, { + beforeSequence: activeAnchor.sourceSeqStart, + limit: 1, + threadId: thread.id, + }).length > 0; + const storedActiveAnchor = hasEarlierConversation + ? getTimelineSegmentAnchorAtSequence(db, { + sequence: activeAnchor.sourceSeqStart, + threadId: thread.id, + }) + : null; + if (hasEarlierConversation && !storedActiveAnchor) { + throw new Error( + `Active timeline anchor ${activeAnchor.id} has no stored segment anchor`, + ); + } + const olderConversationCursor: TimelinePaginationCursor | null = + storedActiveAnchor + ? { + anchorId: storedActiveAnchor.rowId, + anchorSeq: storedActiveAnchor.sequence, + } + : null; + return { + eventWindowOlderCursor: olderConversationCursor, + rows: collapsedRows, + }; +} + function completeThreadTimelineBuildProfile( accumulator: ThreadTimelineBuildProfileAccumulator, options: BuildThreadTimelineOptions, @@ -918,6 +1570,8 @@ function completeThreadTimelineBuildProfile( contextWindowEventDataBytes: accumulator.contextWindowEventDataBytes, contextWindowEventRowCount: accumulator.contextWindowEventRowCount, decodedEventCount: accumulator.decodedEventCount, + enrichmentEventBytes: accumulator.enrichmentEventBytes, + enrichmentEventRowCount: accumulator.enrichmentEventRowCount, eventDataBytes: accumulator.eventDataBytes, eventRowCount: accumulator.eventRowCount, pageKind: options.page.kind, @@ -957,13 +1611,19 @@ function buildThreadTimelineInternal( profile, "accepted-client-request-context-query", () => - mergeStoredEventRowsById([ - ...eventSelection.acceptedClientRequestContextRows, - ...selectAcceptedClientRequestContextRows(db, { - rows: rawEventRows, - threadId: thread.id, - }), - ]), + boundTimelineEventRowsForProjection( + mergeStoredEventRowsById([ + ...eventSelection.acceptedClientRequestContextRows, + ...selectAcceptedClientRequestContextRows(db, { + budget: eventSelection.enrichmentBudget, + excludedRowIds: eventSelection.acceptedClientRequestContextRows.map( + (row) => row.id, + ), + rows: rawEventRows, + threadId: thread.id, + }), + ]), + ), ); const decodedRawEvents = measureThreadTimelineStage( profile, @@ -984,16 +1644,34 @@ function buildThreadTimelineInternal( const contextWindowUsageRows = measureThreadTimelineStage( profile, "context-window-query", - () => - listContextWindowUsageRows(db, { + () => { + const budget = eventSelection.enrichmentBudget; + if (budget.remainingRows === 0 || budget.remainingBytes === 0) { + return []; + } + const result = listContextWindowUsageRows(db, { + excludedRowIds: [ + ...rawEventRows, + ...acceptedClientRequestContextRows, + ].map((row) => row.id), + limit: Math.min(2, budget.remainingRows), + maxBytes: budget.remainingBytes, threadId: thread.id, - }), + }); + return consumeTimelineEnrichmentResult(result, budget); + }, ); if (profile) { profile.contextWindowEventDataBytes = byteLengthOfStoredEventRows( contextWindowUsageRows, ); profile.contextWindowEventRowCount = contextWindowUsageRows.length; + profile.enrichmentEventBytes = + THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET - + eventSelection.enrichmentBudget.remainingBytes; + profile.enrichmentEventRowCount = + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT - + eventSelection.enrichmentBudget.remainingRows; } const commonProjectionOptions = { includeDebugRawEvents: false, @@ -1031,13 +1709,69 @@ function buildThreadTimelineInternal( }, }), ); + const shouldApplyTopLevelLifecycleOwnership = + !isActivelyRunningThread(thread); + const rowsWithDelegationPages = attachDelegationChildPages(db, { + rows: timeline.rows, + sequenceEnd: eventSelection.lifecycleOwnerSequenceEnd, + sequenceStart: eventSelection.lifecycleOwnerSequenceStart, + threadId: thread.id, + }); + const topLevelRows = shouldApplyTopLevelLifecycleOwnership + ? (() => { + const candidateItemIds = [ + ...new Set( + rawEventRows.flatMap((row) => + row.itemId === null ? [] : [row.itemId], + ), + ), + ]; + const ownerSequenceByItemId = new Map( + listStoredItemLifecycleOwnerSequences(db, { + itemIds: candidateItemIds, + seqEnd: eventSelection.lifecycleOwnerSequenceEnd, + seqStart: eventSelection.lifecycleOwnerSequenceStart, + threadId: thread.id, + }).map((owner) => [owner.itemId, owner.sequence]), + ); + const contextOnlyItemIds = new Set( + candidateItemIds.filter((itemId) => { + const ownerSequence = ownerSequenceByItemId.get(itemId); + return ( + ownerSequence === undefined || + ownerSequence < eventSelection.exactEventSequenceStart || + ownerSequence > eventSelection.exactEventSequenceEnd + ); + }), + ); + return excludeTimelineDetailContextItems( + rowsWithDelegationPages, + contextOnlyItemIds, + ); + })() + : rowsWithDelegationPages; if (profile) { - profile.projectedRowCount = timeline.rows.length; + profile.projectedRowCount = topLevelRows.length; } + const delivery = + options.page.kind === "latest" + ? prepareLatestTimelineDelivery({ + db, + eventWindowOlderCursor: eventSelection.eventWindowOlderCursor, + rows: topLevelRows, + thread, + }) + : { + eventWindowOlderCursor: eventSelection.eventWindowOlderCursor, + rows: topLevelRows, + }; const paginatedTimeline = measureThreadTimelineStage( profile, "pagination-segmentation", - () => paginateTimelineRows(timeline.rows, eventSelection.paginationPage), + () => + paginateTimelineRows(delivery.rows, eventSelection.paginationPage, { + eventWindowOlderCursor: delivery.eventWindowOlderCursor, + }), ); if (profile) { profile.responseRowCount = paginatedTimeline.rows.length; @@ -1094,6 +1828,21 @@ export function buildThreadTimeline( }).response; } +export function buildThreadTimelineWithProfile( + db: DbConnection, + thread: Thread, + options: BuildThreadTimelineOptions, +): { profile: ThreadTimelineBuildProfile; response: ThreadTimelineResponse } { + const result = buildThreadTimelineInternal(db, thread, { + ...options, + includeProfile: true, + }); + if (!result.profile) { + throw new Error("Expected timeline build profile"); + } + return { profile: result.profile, response: result.response }; +} + export interface BuildThreadConversationOutlineOptions { /** Thread high-water event sequence this outline reflects (echoed to clients). */ maxSeq: number; @@ -1139,9 +1888,8 @@ export function buildThreadConversationOutline( thread: Thread, options: BuildThreadConversationOutlineOptions, ): ThreadConversationOutlineResponse { - const rawEventRows = listRecentStoredEventRows(db, { + const rawEventRows = listStoredConversationOutlineEventRows(db, { threadId: thread.id, - excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, }); const decodedRawEvents = rawEventRows.map((row) => toThreadEventWithMeta(row), @@ -1149,6 +1897,10 @@ export function buildThreadConversationOutline( const decodedEvents = compactThreadTimelineSummaryEvents(decodedRawEvents); const acceptedClientRequestContext: AcceptedClientRequestContext = { acceptedClientRequestEvents: selectAcceptedClientRequestContextRows(db, { + budget: { + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + }, rows: rawEventRows, threadId: thread.id, }).map((row) => toThreadEventWithMeta(row)), @@ -1187,11 +1939,376 @@ export function buildThreadConversationOutline( return { items, maxSeq: options.maxSeq }; } +function timelineWorkRowItemId( + row: Extract, +): string { + switch (row.workKind) { + case "approval": + return row.target.itemId; + case "question": + return row.interactionId; + case "workflow": + return row.itemId; + case "command": + case "delegation": + case "file-change": + case "image-view": + case "tool": + case "web-fetch": + case "web-search": + return row.callId; + } +} + +function attachDelegationChildPages( + db: DbConnection, + args: { + rows: readonly TimelineRow[]; + sequenceEnd: number; + sequenceStart: number; + threadId: string; + }, +): TimelineRow[] { + const delegationRoots = new Map< + string, + { ownerTurnId: string; parentToolCallId: string } + >(); + const collect = (rows: readonly TimelineRow[]): void => { + for (const row of rows) { + if (row.kind === "turn" && row.children) { + collect(row.children); + } else if (row.kind === "work" && row.workKind === "delegation") { + if (row.turnId !== null) { + delegationRoots.set(row.callId, { + ownerTurnId: row.turnId, + parentToolCallId: row.callId, + }); + } + collect(row.childRows); + } + } + }; + collect(args.rows); + const turnRanges = new Map( + listStoredTurnDescendantRanges(db, { + roots: args.rows.flatMap((row) => + row.kind === "turn" + ? [ + { + sourceSeqEnd: row.sourceSeqEnd, + sourceSeqStart: row.sourceSeqStart, + turnId: row.turnId, + }, + ] + : [], + ), + sequenceEnd: args.sequenceEnd, + threadId: args.threadId, + }).map((range) => [ + `${range.turnId}\0${range.sourceSeqStart}\0${range.sourceSeqEnd}`, + range, + ]), + ); + const rangeByParentToolCallId = new Map( + listStoredDelegationDescendantRanges(db, { + roots: [...delegationRoots.values()], + sequenceEnd: args.sequenceEnd, + sequenceStart: args.sequenceStart, + threadId: args.threadId, + }).map((range) => [range.parentToolCallId, range]), + ); + + const attach = (rows: readonly TimelineRow[]): TimelineRow[] => + rows.map((row): TimelineRow => { + if (row.kind === "turn") { + const children = row.children === null ? null : attach(row.children); + const descendantRange = turnRanges.get( + `${row.turnId}\0${row.sourceSeqStart}\0${row.sourceSeqEnd}`, + ); + return { + ...row, + children, + sourceSeqEnd: Math.max( + row.sourceSeqEnd, + descendantRange?.descendantSourceSeqEnd ?? row.sourceSeqEnd, + ...(children ?? []).map((child) => child.sourceSeqEnd), + ), + }; + } + if (row.kind !== "work" || row.workKind !== "delegation") { + return row; + } + const range = rangeByParentToolCallId.get(row.callId); + if (!range || row.turnId === null) { + return { ...row, childRows: attach(row.childRows) }; + } + const ownerTurnId = row.turnId; + const childRows = attach(row.childRows).filter( + (child) => child.turnId === null || child.turnId === ownerTurnId, + ); + const retainedAnchorRows = new Map(); + for (const childRow of childRows) { + if (!retainedAnchorRows.has(childRow.sourceSeqStart)) { + retainedAnchorRows.set(childRow.sourceSeqStart, childRow); + } + } + const anchors = [...retainedAnchorRows.entries()].sort( + ([left], [right]) => left - right, + ); + const candidateIntervals: TimelineDelegationChildInterval[] = []; + let directTurnSourceSeqStart = range.sourceSeqStart; + for (const [anchor, anchorRow] of anchors) { + const directTurnSourceSeqEnd = Math.min(range.sourceSeqEnd, anchor - 1); + if (directTurnSourceSeqStart <= directTurnSourceSeqEnd) { + candidateIntervals.push({ + beforeChildRowId: anchorRow.id, + directTurnSourceSeqEnd, + directTurnSourceSeqStart, + }); + } + directTurnSourceSeqStart = Math.max( + directTurnSourceSeqStart, + anchor + 1, + ); + } + if (directTurnSourceSeqStart <= range.sourceSeqEnd) { + candidateIntervals.push({ + beforeChildRowId: null, + directTurnSourceSeqEnd: range.sourceSeqEnd, + directTurnSourceSeqStart, + }); + } + const nonemptyBucketIndexes = new Set( + listStoredNonemptyDelegationChildTurnBucketIndexes(db, { + buckets: candidateIntervals, + excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + ownerTurnId, + parentToolCallId: row.callId, + sequenceEnd: range.sourceSeqEnd, + sequenceStart: range.sourceSeqStart, + threadId: args.threadId, + }), + ); + const intervals = candidateIntervals.filter((_, index) => + nonemptyBucketIndexes.has(index), + ); + return { + ...row, + childPage: + intervals.length === 0 + ? null + : { + intervals, + ownerTurnId, + parentToolCallId: row.callId, + sourceSeqEnd: range.sourceSeqEnd, + sourceSeqStart: range.sourceSeqStart, + }, + childRows, + sourceSeqEnd: Math.max(row.sourceSeqEnd, range.sourceSeqEnd), + }; + }); + return attach(args.rows); +} + +/** + * Lifecycle starts older than a synthetic summary are projection context, not + * members of that summary. Their later deltas can overlap omitted work in + * source-sequence space, so emitting them here would duplicate a pending row + * that remains visible beside the summary. Nested children are lifted when + * only their context parent is suppressed so in-range child work stays + * reachable. + */ +function excludeTimelineDetailContextItems( + rows: readonly TimelineRow[], + contextOnlyItemIds: ReadonlySet, +): TimelineRow[] { + return rows.flatMap((row): TimelineRow[] => { + if (row.kind === "turn") { + const children = row.children + ? excludeTimelineDetailContextItems(row.children, contextOnlyItemIds) + : null; + return [{ ...row, children }]; + } + if (row.kind !== "work") { + return [row]; + } + + const itemId = timelineWorkRowItemId(row); + if (contextOnlyItemIds.has(itemId)) { + return row.workKind === "delegation" + ? excludeTimelineDetailContextItems(row.childRows, contextOnlyItemIds) + : []; + } + if (row.workKind !== "delegation") { + return [row]; + } + return [ + { + ...row, + childRows: excludeTimelineDetailContextItems( + row.childRows, + contextOnlyItemIds, + ), + }, + ]; + }); +} + +export function buildTimelineDelegationChildrenDetails( + db: DbConnection, + thread: Thread, + options: BuildTimelineDelegationChildrenDetailsOptions, +): TimelineTurnSummaryDetailsResponse { + if (options.sourceSeqStart > options.sourceSeqEnd) { + throw new ApiError( + 400, + "invalid_request", + "sourceSeqStart must be less than or equal to sourceSeqEnd", + ); + } + if ( + options.directTurnSourceSeqStart > options.directTurnSourceSeqEnd || + options.directTurnSourceSeqStart < options.sourceSeqStart || + options.directTurnSourceSeqEnd > options.sourceSeqEnd + ) { + throw new ApiError( + 400, + "invalid_request", + "Delegation child interval must be ordered within the delegation snapshot", + ); + } + const ownerRow = listStoredToolCallRowsByItemIds(db, { + itemIds: [options.parentToolCallId], + limit: 2, + maxBytes: THREAD_TIMELINE_PARENTED_LIFECYCLE_BYTE_RESERVE, + sequenceEnd: options.sourceSeqEnd, + threadId: thread.id, + }).rows.find( + (row) => + row.itemId === options.parentToolCallId && row.turnId === options.turnId, + ); + if (!ownerRow) { + throw new ApiError( + 400, + "invalid_request", + `Delegation ${options.parentToolCallId} is not owned by turn ${options.turnId}`, + ); + } + + const cursorScope: TimelineEventWindowCursorScope = { + kind: "delegation-children", + directTurnSourceSeqEnd: options.directTurnSourceSeqEnd, + directTurnSourceSeqStart: options.directTurnSourceSeqStart, + ownerTurnId: options.turnId, + parentToolCallId: options.parentToolCallId, + sourceSeqEnd: options.sourceSeqEnd, + sourceSeqStart: options.sourceSeqStart, + threadId: thread.id, + }; + let beforeSequence: number | undefined; + if (options.beforeCursor !== null) { + beforeSequence = requireStoredTimelineEventWindowCursor(db, { + cursor: options.beforeCursor, + errorMessage: "Timeline delegation child cursor is no longer available", + expectedScope: cursorScope, + rowLimit: THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT, + threadId: thread.id, + }).sequence; + } + const candidates = listStoredDelegationChildTurnRanges(db, { + beforeSequence, + directTurnSourceSeqEnd: options.directTurnSourceSeqEnd, + directTurnSourceSeqStart: options.directTurnSourceSeqStart, + excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + limit: THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT + 1, + ownerTurnId: options.turnId, + parentToolCallId: options.parentToolCallId, + sequenceEnd: options.sourceSeqEnd, + sequenceStart: options.sourceSeqStart, + threadId: thread.id, + }); + const selectedDescending = candidates.slice( + 0, + THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT, + ); + const descendantRangeByTurnId = new Map( + listStoredDelegatedTurnDescendantRanges(db, { + roots: selectedDescending.map((range) => ({ + parentToolCallId: range.parentToolCallId, + turnId: range.turnId, + })), + sequenceEnd: options.sourceSeqEnd, + sequenceStart: options.sourceSeqStart, + threadId: thread.id, + }).map((range) => [range.turnId, range]), + ); + const rows: TimelineRow[] = selectedDescending + .map((directRange) => { + const range = descendantRangeByTurnId.get(directRange.turnId); + if (!range) { + throw new Error( + `Missing descendant range for delegated turn ${directRange.turnId}`, + ); + } + return { + id: `${thread.id}:${range.turnId}:delegated-turn:${options.parentToolCallId}`, + threadId: thread.id, + turnId: range.turnId, + detailContextItemIds: [], + detailParentToolCallId: options.parentToolCallId, + sourceSeqStart: range.sourceSeqStart, + sourceSeqEnd: range.sourceSeqEnd, + startedAt: range.startedAt, + createdAt: range.createdAt, + kind: "turn" as const, + status: + range.completedAt === null + ? ("pending" as const) + : ("completed" as const), + summaryCount: range.eventCount, + completedAt: range.completedAt, + children: null, + }; + }) + .reverse(); + const hasOlderRows = + candidates.length > THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT; + const oldestSelected = selectedDescending.at(-1); + let olderCursor: TimelinePaginationCursor | null = null; + if (hasOlderRows && oldestSelected) { + const cursorEvent = getStoredEventIdentityAtSequence(db, { + sequence: oldestSelected.sourceSeqStart, + threadId: thread.id, + }); + if (!cursorEvent) { + throw new Error( + `Missing delegation child cursor event ${oldestSelected.sourceSeqStart}`, + ); + } + olderCursor = createTimelineEventWindowCursor({ + byteTarget: THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + eventId: cursorEvent.id, + issuedBeforeSequence: + beforeSequence ?? options.directTurnSourceSeqEnd + 1, + rowLimit: THREAD_TIMELINE_DELEGATION_CHILD_PAGE_LIMIT, + scope: cursorScope, + selectionStart: options.directTurnSourceSeqStart, + sequence: cursorEvent.sequence, + }); + } + return { + rows, + timelinePage: { hasOlderRows, olderCursor }, + }; +} + export function buildTimelineTurnSummaryDetails( db: DbConnection, thread: Thread, options: BuildTimelineTurnSummaryDetailsOptions, ): TimelineTurnSummaryDetailsResponse { + const parentToolCallId = options.parentToolCallId ?? null; if (options.sourceSeqStart > options.sourceSeqEnd) { throw new ApiError( 400, @@ -1199,28 +2316,125 @@ export function buildTimelineTurnSummaryDetails( "sourceSeqStart must be less than or equal to sourceSeqEnd", ); } + if ( + parentToolCallId !== null && + listStoredDelegatedTurnDescendantRanges(db, { + roots: [ + { + parentToolCallId, + turnId: options.turnId, + }, + ], + sequenceEnd: options.sourceSeqEnd, + sequenceStart: options.sourceSeqStart, + threadId: thread.id, + }).length === 0 + ) { + throw new ApiError( + 400, + "invalid_request", + `Timeline delegated turn ${options.turnId} is not owned by ${parentToolCallId}`, + ); + } + if ( + !hasStoredTurnEventInRange(db, { + seqEnd: options.sourceSeqEnd, + seqStart: options.sourceSeqStart, + threadId: thread.id, + turnId: options.turnId, + }) + ) { + throw new ApiError( + 400, + "invalid_request", + `Timeline turn summary details range ${options.sourceSeqStart}-${options.sourceSeqEnd} does not include turn ${options.turnId}`, + ); + } const includeProviderUnhandledOperations = options.includeProviderUnhandledOperations; - const exactEventRows = listStoredEventRowsInRange(db, { + const cursorScope: TimelineEventWindowCursorScope = { + kind: "turn-details", + contextItemIdsHash: hashTimelineTurnDetailsContextItemIds( + options.contextItemIds, + ), + parentToolCallId, + sourceSeqEnd: options.sourceSeqEnd, + sourceSeqStart: options.sourceSeqStart, + threadId: thread.id, + turnId: options.turnId, + }; + let beforeSequence = options.sourceSeqEnd + 1; + if (options.beforeCursor !== null) { + const cursorEvent = requireStoredTimelineEventWindowCursor(db, { + cursor: options.beforeCursor, + errorMessage: + "Timeline turn detail pagination cursor is no longer available", + expectedScope: cursorScope, + threadId: thread.id, + }); + if ( + cursorEvent.sequence <= options.sourceSeqStart || + cursorEvent.sequence > options.sourceSeqEnd + ) { + throw new ApiError( + 400, + "invalid_request", + "Timeline turn detail pagination cursor is outside the requested range", + ); + } + beforeSequence = cursorEvent.sequence; + } + const boundedEventRows = selectBoundedTimelineEventRows(db, { + beforeSequence, + cursorScope, + sequenceStart: options.sourceSeqStart, threadId: thread.id, - seqStart: options.sourceSeqStart, - seqEnd: options.sourceSeqEnd, }); + const exactEventRows = boundedEventRows.rows; + const firstExactEventRow = exactEventRows[0]; + const lastExactEventRow = exactEventRows.at(-1); + if (!firstExactEventRow || !lastExactEventRow) { + throw new ApiError( + 400, + "invalid_request", + "Timeline turn detail pagination cursor has no older rows", + ); + } const clientRequestIds = listStoredClientTurnRequestIdsInRange(db, { threadId: thread.id, - seqStart: options.sourceSeqStart, - seqEnd: options.sourceSeqEnd, + seqStart: firstExactEventRow.sequence, + seqEnd: lastExactEventRow.sequence, }); const exactAcceptedInputRows = exactEventRows.filter( (row) => row.type === "turn/input/accepted", ); - const futureAcceptedInputRows = + const enrichmentBudget: TimelineParentedEnrichmentBudget = { + remainingBytes: THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + remainingRows: THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + }; + const futureAcceptedInputResult = listStoredTurnInputAcceptedRowsByClientRequestIds(db, { + beforeOrAtSequence: options.sourceSeqEnd, threadId: thread.id, - afterSequence: options.sourceSeqEnd, + afterSequence: lastExactEventRow.sequence, clientRequestIds, + excludedRowIds: exactEventRows.map((row) => row.id), + limit: Math.max( + 0, + enrichmentBudget.remainingRows - + THREAD_TIMELINE_PARENTED_LIFECYCLE_ROW_RESERVE, + ), + maxBytes: Math.max( + 0, + enrichmentBudget.remainingBytes - + THREAD_TIMELINE_PARENTED_LIFECYCLE_BYTE_RESERVE, + ), }); + const futureAcceptedInputRows = consumeTimelineEnrichmentResult( + futureAcceptedInputResult, + enrichmentBudget, + ); const acceptedInputRowsByTurn = partitionAcceptedInputRowsByRequestedTurn({ acceptedInputRows: [...exactAcceptedInputRows, ...futureAcceptedInputRows], turnId: options.turnId, @@ -1236,17 +2450,6 @@ export function buildTimelineTurnSummaryDetails( ...acceptedInputRowsByTurn.requestedTurnRows, ]); - const hasTurnScopedRowsForRequestedTurn = eventRows.some( - (row) => row.scopeKind === "turn" && row.turnId === options.turnId, - ); - if (!hasTurnScopedRowsForRequestedTurn) { - throw new ApiError( - 400, - "invalid_request", - `Timeline turn summary details range ${options.sourceSeqStart}-${options.sourceSeqEnd} does not include turn ${options.turnId}`, - ); - } - const hasCurrentStartedRow = eventRows.some( (row) => row.type === "turn/started" && row.turnId === options.turnId, ); @@ -1258,13 +2461,22 @@ export function buildTimelineTurnSummaryDetails( // validated against the requested turn, that turn's start must be at or // before the latest selected turn row. Accepted input rows may sit after // sourceSeqEnd, so the lifecycle lookup uses the widened context cutoff. - const requestedTurnStartedRows = hasCurrentStartedRow - ? [] + const requestedTurnStartedResult = hasCurrentStartedRow + ? null : listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + excludedRowIds: eventRows.map((row) => row.id), + limit: enrichmentBudget.remainingRows, + maxBytes: enrichmentBudget.remainingBytes, threadId: thread.id, sequenceCutoff: contextSequenceCutoff, turnIds: [options.turnId], }); + const requestedTurnStartedRows = requestedTurnStartedResult + ? consumeTimelineEnrichmentResult( + requestedTurnStartedResult, + enrichmentBudget, + ) + : []; if (!hasCurrentStartedRow && requestedTurnStartedRows.length === 0) { throw new ApiError( 400, @@ -1275,44 +2487,135 @@ export function buildTimelineTurnSummaryDetails( const sourceRange = resolveTurnSummaryDetailsSourceRange({ exactEventRows: exactEventRowsForRequestedTurn.rows, fallbackRange: { - sourceSeqEnd: options.sourceSeqEnd, - sourceSeqStart: options.sourceSeqStart, + sourceSeqEnd: lastExactEventRow.sequence, + sourceSeqStart: firstExactEventRow.sequence, turnId: options.turnId, }, useExactEventRowBounds: exactEventRowsForRequestedTurn.removedRows, }); - const eventRowsWithParentedChildren = ensureTimelineWindowParentedRows(db, { - includeParentContext: false, + const preParentedRows = boundTimelineEventRowsForProjection( + ensureTimelineWindowItemStartedRows(db, { + budget: enrichmentBudget, + sequenceEnd: options.sourceSeqEnd, + threadId: thread.id, + rows: mergeStoredEventRowsById([ + ...requestedTurnStartedRows, + ...eventRows, + ]), + }), + ); + const parentedRootOwnerSequenceByItemId = new Map( + listStoredItemLifecycleOwnerSequences(db, { + itemIds: collectStoredToolCallItemIds(preParentedRows), + seqEnd: options.sourceSeqEnd, + seqStart: options.sourceSeqStart, + threadId: thread.id, + }).map((owner) => [owner.itemId, owner.sequence]), + ); + const pageOwnedParentedRootToolCallIds = new Set( + collectStoredToolCallItemIds(preParentedRows).filter((itemId) => { + const ownerSequence = parentedRootOwnerSequenceByItemId.get(itemId); + return ( + ownerSequence !== undefined && + ownerSequence >= firstExactEventRow.sequence && + ownerSequence <= lastExactEventRow.sequence + ); + }), + ); + const parentedEventSelection = ensureTimelineWindowParentedRows(db, { + budget: enrichmentBudget, + parentedRootToolCallIds: pageOwnedParentedRootToolCallIds, + sequenceEnd: options.sourceSeqEnd, + sequenceStart: firstExactEventRow.sequence, threadId: thread.id, - rows: mergeStoredEventRowsById([...requestedTurnStartedRows, ...eventRows]), - }).rows; + rows: preParentedRows, + }); + const eventRowsWithParentedChildren = parentedEventSelection.rows; const eventRowsWithTurnStarts = ensureTimelineWindowTurnStartedRows(db, { + budget: enrichmentBudget, threadId: thread.id, rows: eventRowsWithParentedChildren, }); - const eventRowsWithBackgroundTaskState = + const eventRowsWithBackgroundTaskState = boundTimelineEventRowsForProjection( ensureTimelineWindowBackgroundTaskStateRows(db, { + budget: enrichmentBudget, + sequenceEnd: options.sourceSeqEnd, threadId: thread.id, rows: eventRowsWithTurnStarts, - }); + }), + ); + const projectionSourceSeqEnd = Math.max( + sourceRange.sourceSeqEnd, + maxStoredEventSequence(eventRowsWithBackgroundTaskState), + ); const children = buildThreadTimelineTurnDetailsFromEvents({ events: eventRowsWithBackgroundTaskState.map((row) => toThreadEventWithMeta(row), ), options: { includeProviderUnhandledOperations, - sourceSeqEnd: sourceRange.sourceSeqEnd, + sourceSeqEnd: projectionSourceSeqEnd, sourceSeqStart: sourceRange.sourceSeqStart, providerDisplayName: options.providerDisplayName, threadStatus: thread.status, threadName: thread.title ?? thread.titleFallback ?? "", + turnId: options.turnId, workspaceRoot: resolveThreadWorkspaceRoot(db, thread), }, }); if (children.kind !== "missing-match") { + // Give each item one deterministic detail page: the page containing its + // newest lifecycle root inside the summary (item/completed when present, + // otherwise item/started). Backfilled starts and delta-only pages are + // projection context. This ownership does not depend on whether a newer + // page happened to fit the lifecycle into its enrichment budget. + const candidateItemIds = [ + ...new Set( + eventRowsWithBackgroundTaskState.flatMap((row) => + row.itemId === null ? [] : [row.itemId], + ), + ), + ]; + const lifecycleOwnerSequenceByItemId = new Map( + listStoredItemLifecycleOwnerSequences(db, { + itemIds: candidateItemIds, + seqEnd: options.sourceSeqEnd, + seqStart: options.sourceSeqStart, + threadId: thread.id, + }).map((owner) => [owner.itemId, owner.sequence]), + ); + const contextOnlyItemIds = new Set( + candidateItemIds.filter((itemId) => { + const ownerSequence = lifecycleOwnerSequenceByItemId.get(itemId); + return ( + ownerSequence === undefined || + ownerSequence < firstExactEventRow.sequence || + ownerSequence > lastExactEventRow.sequence + ); + }), + ); + for (const contextItemId of options.contextItemIds) { + contextOnlyItemIds.add(contextItemId); + } + const detailRows = attachDelegationChildPages(db, { + rows: excludeTimelineDetailContextItems( + children.rows, + contextOnlyItemIds, + ), + sequenceEnd: options.sourceSeqEnd, + sequenceStart: options.sourceSeqStart, + threadId: thread.id, + }); + const page = paginateTimelineTurnDetails(detailRows, { + eventWindowOlderCursor: boundedEventRows.olderCursor, + }); return { - rows: children.rows, + rows: page.rows, + timelinePage: { + hasOlderRows: page.hasOlderRows, + olderCursor: page.olderCursor, + }, }; } diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 33ceb98483..1c5aebeece 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -234,10 +234,11 @@ describe("public thread data routes", () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); - // Three message turns, each: user request -> turn start -> assistant - // message -> turn complete. Segment anchors are the user-message rows, so - // a `segmentLimit=1` timeline page exposes only the last turn while the - // outline must still cover all three. + // Three completed message turns, each with an intermediate assistant + // message that projection folds into the completed-turn summary. Segment + // anchors are the user-message rows, so a `segmentLimit=1` timeline page + // exposes only the last turn while the outline must still cover all three + // without resurrecting those folded intermediate messages. const seedMessageTurn = (args: { requestId: number; startSequence: number; @@ -286,6 +287,21 @@ describe("public thread data routes", () => { scope: turnScope(args.turnId), sequence: args.startSequence + 2, type: "item/completed", + data: { + item: { + type: "agentMessage", + id: `${args.turnId}-intermediate`, + text: `${args.text} — still working.`, + }, + }, + }); + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId: "provider-thread-1", + scope: turnScope(args.turnId), + sequence: args.startSequence + 3, + type: "item/completed", data: { item: { type: "agentMessage", @@ -299,7 +315,7 @@ describe("public thread data routes", () => { environmentId: environment.id, providerThreadId: "provider-thread-1", scope: turnScope(args.turnId), - sequence: args.startSequence + 3, + sequence: args.startSequence + 4, type: "turn/completed", data: { status: "completed" }, }); @@ -313,13 +329,13 @@ describe("public thread data routes", () => { }); seedMessageTurn({ requestId: 102, - startSequence: 5, + startSequence: 6, text: "Second question", turnId: "turn-2", }); seedMessageTurn({ requestId: 103, - startSequence: 9, + startSequence: 11, text: "Third question", turnId: "turn-3", }); @@ -355,7 +371,7 @@ describe("public thread data routes", () => { expect(outline.items.length).toBeGreaterThan( windowedConversationIds.length, ); - expect(outline.maxSeq).toBe(12); + expect(outline.maxSeq).toBe(15); expect(outline.items.map((item) => item.preview)).toEqual([ "First question", "First question — answered.", @@ -371,6 +387,19 @@ describe("public thread data routes", () => { for (const id of windowedConversationIds) { expect(outlineIds.has(id)).toBe(true); } + + const fullTimelineResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline?segmentLimit=20`, + ); + expect(fullTimelineResponse.status).toBe(200); + const fullTimeline = threadTimelineResponseSchema.parse( + await readJson(fullTimelineResponse), + ); + expect(outline.items.map((item) => item.id)).toEqual( + fullTimeline.rows + .filter((row) => row.kind === "conversation") + .map((row) => row.id), + ); }); }); @@ -704,7 +733,7 @@ describe("public thread data routes", () => { expect(turnRow.children).toBeNull(); const toolDetailsResponse = 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-summary-details?detailKind=turn&turnId=${turnRow.turnId}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`, ); expect(toolDetailsResponse.status).toBe(200); const toolDetails = timelineTurnSummaryDetailsResponseSchema.parse( @@ -721,7 +750,7 @@ describe("public thread data routes", () => { }); }); - it("hydrates turn-summary workflow details with late background task completion", async () => { + it("keeps completed turn-summary details inside their captured background-task range", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); const taskData = ( @@ -811,7 +840,7 @@ describe("public thread data routes", () => { } 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-summary-details?detailKind=turn&turnId=${turnRow.turnId}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`, ); expect(detailsResponse.status).toBe(200); const details = timelineTurnSummaryDetailsResponseSchema.parse( @@ -832,10 +861,10 @@ describe("public thread data routes", () => { throw new Error("Expected a workflow detail row"); } - expect(workflowRow.status).toBe("completed"); - expect(workflowRow.taskStatus).toBe("completed"); - expect(workflowRow.summary).toBe("done"); - expect(workflowRow.completedAt).not.toBeNull(); + expect(workflowRow.status).toBe("pending"); + expect(workflowRow.taskStatus).toBe("running"); + expect(workflowRow.summary).toBeNull(); + expect(workflowRow.completedAt).toBeNull(); }); }); @@ -1132,7 +1161,7 @@ describe("public thread data routes", () => { } const detailsResponse = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=${childTurnRow.turnId}&sourceSeqStart=${childTurnRow.sourceSeqStart}&sourceSeqEnd=${childTurnRow.sourceSeqEnd}`, + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=${childTurnRow.turnId}&sourceSeqStart=${childTurnRow.sourceSeqStart}&sourceSeqEnd=${childTurnRow.sourceSeqEnd}`, ); expect(detailsResponse.status).toBe(200); const details = timelineTurnSummaryDetailsResponseSchema.parse( @@ -1150,7 +1179,7 @@ describe("public thread data routes", () => { }); }); - it("hydrates parent turn-summary details with delegated child rows", async () => { + it("hydrates delegated child turns through their lazy boundary", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); const providerThreadId = "provider-thread-1"; @@ -1267,8 +1296,27 @@ describe("public thread data routes", () => { throw new Error("Expected parent turn row"); } + // The captured parent range owns the existing child output through 7. + // A later append must not leak into expansion of that immutable range. + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId, + scope: turnScope("child-turn"), + sequence: 8, + type: "item/completed", + data: { + item: { + type: "agentMessage", + id: "later-child-message", + text: "Later child output outside the captured summary.", + parentToolCallId: "agent-call", + }, + }, + }); + const detailsResponse = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=${parentTurnRow.turnId}&sourceSeqStart=${parentTurnRow.sourceSeqStart}&sourceSeqEnd=${parentTurnRow.sourceSeqEnd}`, + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=${parentTurnRow.turnId}&sourceSeqStart=${parentTurnRow.sourceSeqStart}&sourceSeqEnd=${parentTurnRow.sourceSeqEnd}`, ); expect(detailsResponse.status).toBe(200); const details = timelineTurnSummaryDetailsResponseSchema.parse( @@ -1285,12 +1333,56 @@ describe("public thread data routes", () => { expect(delegation).toBeDefined(); expect(delegation?.callId).toBe("agent-call"); - expect(delegation?.childRows).toContainEqual( + expect(delegation?.childRows).toEqual([]); + expect(delegation?.childPage).toMatchObject({ + ownerTurnId: "parent-turn", + parentToolCallId: "agent-call", + sourceSeqEnd: 7, + }); + if (!delegation?.childPage) { + throw new Error("Expected a delegated child page"); + } + const childInterval = delegation.childPage.intervals[0]; + if (!childInterval) { + throw new Error("Expected a delegated child interval"); + } + + const childPageResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=delegation-children&turnId=${delegation.childPage.ownerTurnId}&parentToolCallId=${delegation.childPage.parentToolCallId}&sourceSeqStart=${delegation.childPage.sourceSeqStart}&sourceSeqEnd=${delegation.childPage.sourceSeqEnd}&directTurnSourceSeqStart=${childInterval.directTurnSourceSeqStart}&directTurnSourceSeqEnd=${childInterval.directTurnSourceSeqEnd}`, + ); + expect(childPageResponse.status).toBe(200); + const childPage = timelineTurnSummaryDetailsResponseSchema.parse( + await readJson(childPageResponse), + ); + const childTurn = childPage.rows.find( + (row): row is TimelineTurnRow => + row.kind === "turn" && row.turnId === "child-turn", + ); + expect(childTurn).toBeDefined(); + expect(childTurn?.detailParentToolCallId).toBe("agent-call"); + if (!childTurn) { + throw new Error("Expected a delegated child turn"); + } + + const childDetailsResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=${childTurn.turnId}&parentToolCallId=agent-call&sourceSeqStart=${childTurn.sourceSeqStart}&sourceSeqEnd=${childTurn.sourceSeqEnd}`, + ); + expect(childDetailsResponse.status).toBe(200); + const childDetails = timelineTurnSummaryDetailsResponseSchema.parse( + await readJson(childDetailsResponse), + ); + expect(childDetails.rows).toContainEqual( expect.objectContaining({ kind: "conversation", text: "Child mapped the Telegram integration.", }), ); + expect(childDetails.rows).not.toContainEqual( + expect.objectContaining({ + kind: "conversation", + text: "Later child output outside the captured summary.", + }), + ); }); }); @@ -1417,7 +1509,7 @@ describe("public thread data routes", () => { }); const detailsResponse = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=requested-turn&sourceSeqStart=1&sourceSeqEnd=5`, + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=requested-turn&sourceSeqStart=1&sourceSeqEnd=7`, ); expect(detailsResponse.status).toBe(200); const details = timelineTurnSummaryDetailsResponseSchema.parse( @@ -1472,13 +1564,21 @@ describe("public thread data routes", () => { }); const detailsResponse = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=turn-1&sourceSeqStart=2&sourceSeqEnd=2`, + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=turn-1&sourceSeqStart=2&sourceSeqEnd=2`, ); expect(detailsResponse.status).toBe(200); const details = timelineTurnSummaryDetailsResponseSchema.parse( await readJson(detailsResponse), ); + const emptyContextDetailsResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=turn-1&sourceSeqStart=2&sourceSeqEnd=2&contextItemIds=%5B%5D`, + ); + expect(emptyContextDetailsResponse.status).toBe(200); + await expect(readJson(emptyContextDetailsResponse)).resolves.toEqual( + details, + ); + expect(details.rows).toHaveLength(1); expect(details.rows[0]?.kind).toBe("conversation"); if (details.rows[0]?.kind === "conversation") { @@ -1495,12 +1595,20 @@ describe("public thread data routes", () => { const { thread } = seedThreadFixture(harness); const response = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-summary-details?turnId=turn-1&sourceSeqStart=oops&sourceSeqEnd=2`, + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=turn-1&sourceSeqStart=oops&sourceSeqEnd=2`, ); expect(response.status).toBe(400); await expect(readJson(response)).resolves.toMatchObject({ code: "invalid_request", }); + + const invalidContextResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline/turn-summary-details?detailKind=turn&turnId=turn-1&sourceSeqStart=1&sourceSeqEnd=2&contextItemIds=not-json`, + ); + expect(invalidContextResponse.status).toBe(400); + await expect(readJson(invalidContextResponse)).resolves.toMatchObject({ + code: "invalid_request", + }); }); }); diff --git a/apps/server/test/public/public-thread-timeline-delta.test.ts b/apps/server/test/public/public-thread-timeline-delta.test.ts index b6fd7cf783..304f57164e 100644 --- a/apps/server/test/public/public-thread-timeline-delta.test.ts +++ b/apps/server/test/public/public-thread-timeline-delta.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { threadScope, turnScope } from "@bb/domain"; +import { + encodeClientTurnRequestIdNumber, + threadScope, + turnScope, +} from "@bb/domain"; +import { insertEvents } from "@bb/db"; import { applyTimelineDelta, threadTimelineResponseSchema, @@ -9,6 +14,7 @@ import { readJson } from "../helpers/json.js"; import { seedEvent, seedThreadFixture } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; import type { TestAppHarness } from "../helpers/test-app.js"; +import { DEFAULT_MAX_INLINE_OUTPUT_CHARS } from "../../src/services/threads/timeline-output-truncation.js"; async function getTimeline( harness: TestAppHarness, @@ -29,6 +35,168 @@ async function getTimeline( } describe("GET /threads/:id/timeline?afterSequence (row-patch delta)", () => { + it("keeps a long active turn bounded and sends only cached row changes", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedThreadFixture(harness, { + thread: { status: "active" }, + }); + const clientRequestId = encodeClientTurnRequestIdNumber({ value: 1 }); + const outputCount = 840; + insertEvents(harness.deps.db, harness.hub, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + execution: { + model: "gpt-5", + permissionMode: "full", + reasoningLevel: "medium", + serviceTier: "default", + source: "client/turn/requested", + }, + initiator: "user", + input: [ + { + type: "text", + text: "Run for several hours.", + mentions: [], + }, + ], + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + source: "spawn", + target: { kind: "thread-start" }, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("long-active-turn"), + providerThreadId: "provider-thread", + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("long-active-turn"), + providerThreadId: "provider-thread", + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("long-active-turn"), + providerThreadId: "provider-thread", + itemId: "long-active-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "long-active-command", + command: "long-running-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: outputCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 5, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("long-active-turn"), + providerThreadId: "provider-thread", + itemId: "long-active-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "long-active-command", + delta: `cached chunk ${index} ${"x".repeat(256)}\n`, + }), + })), + ]); + + const before = await getTimeline(harness, thread.id); + const prompt = before.rows.find( + (row) => row.kind === "conversation" && row.role === "user", + ); + const pendingCommand = before.rows.find( + (row) => + row.kind === "work" && + row.workKind === "command" && + row.callId === "long-active-command", + ); + const beforeSummary = before.rows.find((row) => row.kind === "turn"); + expect(prompt).toBeDefined(); + expect(pendingCommand).toMatchObject({ status: "pending" }); + expect(beforeSummary).toBeDefined(); + expect(before.rows.length).toBeLessThan(20); + if ( + !pendingCommand || + pendingCommand.kind !== "work" || + pendingCommand.workKind !== "command" + ) { + throw new Error("Expected pending command"); + } + expect(pendingCommand.output.length).toBeGreaterThanOrEqual( + DEFAULT_MAX_INLINE_OUTPUT_CHARS, + ); + expect(pendingCommand.output.length).toBeLessThan( + DEFAULT_MAX_INLINE_OUTPUT_CHARS + 200, + ); + expect(JSON.stringify(before).length).toBeLessThan(50_000); + + insertEvents(harness.deps.db, harness.hub, [ + { + threadId: thread.id, + sequence: outputCount + 5, + type: "item/commandExecution/outputDelta", + scope: turnScope("long-active-turn"), + providerThreadId: "provider-thread", + itemId: "long-active-command", + itemKind: "commandExecution", + data: JSON.stringify({ + itemId: "long-active-command", + delta: "one cached update\n", + }), + }, + ]); + + const delta = await getTimeline(harness, thread.id, before.maxSeq); + expect(delta.rows).toHaveLength(0); + expect(delta.delta).toBeDefined(); + expect(delta.delta?.upsertRows.length).toBeGreaterThan(0); + expect(delta.delta?.upsertRows.map((row) => row.id)).not.toContain( + prompt?.id, + ); + + const merged = applyTimelineDelta(before.rows, delta.delta!); + const fresh = await getTimeline(harness, thread.id); + expect(merged).toEqual(fresh.rows); + const afterSummary = merged?.find((row) => row.kind === "turn"); + expect(afterSummary?.id).toBe(beforeSummary?.id); + expect(afterSummary?.sourceSeqEnd).toBeGreaterThan( + beforeSummary?.sourceSeqEnd ?? 0, + ); + expect(JSON.stringify(delta).length).toBeLessThan(50_000); + expect(JSON.stringify(delta).length).toBeLessThan( + JSON.stringify(fresh).length, + ); + }); + }); + it("a full fetch carries no delta and echoes maxSeq", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); diff --git a/apps/server/test/services/threads/timeline-active-work-window.test.ts b/apps/server/test/services/threads/timeline-active-work-window.test.ts new file mode 100644 index 0000000000..12cedb26cf --- /dev/null +++ b/apps/server/test/services/threads/timeline-active-work-window.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineRow } from "@bb/server-contract"; +import { + ACTIVE_TIMELINE_TAIL_ROW_TARGET, + collapseActiveTimelineWork, +} from "../../../src/services/threads/timeline-active-work-window.js"; + +function userRow(): TimelineRow { + return { + id: "user-1", + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: 1, + sourceSeqEnd: 1, + startedAt: 1, + createdAt: 1, + kind: "conversation", + role: "user", + text: "Do the long task", + attachments: null, + mentions: [], + initiator: "user", + senderThreadId: null, + systemMessageKind: "unlabeled", + systemMessageSubject: null, + turnRequest: { kind: "message", status: "accepted" }, + }; +} + +function workRow( + index: number, + status: "completed" | "pending" = "completed", +): TimelineRow { + return { + id: `work-${index}`, + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + startedAt: index + 1, + createdAt: index + 1, + kind: "work", + workKind: "command", + status, + callId: `call-${index}`, + command: `command ${index}`, + cwd: null, + source: "agent", + output: "", + exitCode: status === "pending" ? null : 0, + completedAt: status === "pending" ? null : index + 1, + approvalStatus: null, + activityIntents: [], + }; +} + +describe("collapseActiveTimelineWork", () => { + it("keeps the prompt and bounded live tail around a lazy pending turn row", () => { + const work = Array.from( + { length: ACTIVE_TIMELINE_TAIL_ROW_TARGET + 20 }, + (_, index) => workRow(index + 1), + ); + const result = collapseActiveTimelineWork({ + rows: [userRow(), ...work], + threadStatus: "active", + }); + + expect(result[0]?.id).toBe("user-1"); + expect(result[1]).toMatchObject({ + id: "thread-1:turn-1:active-turn:2", + kind: "turn", + status: "pending", + summaryCount: 20, + }); + expect(result.slice(2).map((row) => row.id)).toEqual( + work.slice(-ACTIVE_TIMELINE_TAIL_ROW_TARGET).map((row) => row.id), + ); + }); + + it("keeps an old pending row without retaining its unbounded suffix", () => { + const work = Array.from({ length: 2_000 }, (_, index) => + workRow(index + 1, index === 9 ? "pending" : "completed"), + ); + const result = collapseActiveTimelineWork({ + rows: [userRow(), ...work], + threadStatus: "active", + }); + expect(result.some((row) => row.id === "work-10")).toBe(true); + expect(result.length).toBeLessThanOrEqual( + ACTIVE_TIMELINE_TAIL_ROW_TARGET + 4, + ); + expect(result[1]).toMatchObject({ kind: "turn", summaryCount: 9 }); + expect(result[3]).toMatchObject({ + kind: "turn", + summaryCount: 2_000 - ACTIVE_TIMELINE_TAIL_ROW_TARGET - 10, + }); + expect(result.slice(-ACTIVE_TIMELINE_TAIL_ROW_TARGET)).toEqual( + work.slice(-ACTIVE_TIMELINE_TAIL_ROW_TARGET), + ); + }); + + it("keeps a pinned-first row outside the leading historical gap", () => { + const firstPending = workRow(9, "pending"); + const laterWork = Array.from( + { length: ACTIVE_TIMELINE_TAIL_ROW_TARGET + 20 }, + (_, index) => workRow(index + 10), + ); + const result = collapseActiveTimelineWork({ + olderEventSequence: firstPending.sourceSeqStart + 5, + rows: [userRow(), firstPending, ...laterWork], + threadStatus: "active", + }); + const summaries = result.filter( + (row): row is Extract => + row.kind === "turn", + ); + + expect(result[1]).toMatchObject({ + kind: "turn", + sourceSeqEnd: firstPending.sourceSeqStart - 1, + sourceSeqStart: 2, + }); + expect(result[2]?.id).toBe(firstPending.id); + expect(summaries[1]?.sourceSeqStart).toBeGreaterThanOrEqual( + firstPending.sourceSeqEnd + 1, + ); + for (let index = 1; index < result.length; index++) { + expect(result[index]?.sourceSeqStart).toBeGreaterThan( + result[index - 1]?.sourceSeqEnd ?? -1, + ); + } + }); + + it("does not alter an idle timeline", () => { + const rows = [userRow(), workRow(1)]; + expect(collapseActiveTimelineWork({ rows, threadStatus: "idle" })).toEqual( + rows, + ); + }); +}); diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts index 149317db4e..ec9f94d05b 100644 --- a/apps/server/test/services/threads/timeline-cache.test.ts +++ b/apps/server/test/services/threads/timeline-cache.test.ts @@ -4,6 +4,7 @@ import type { ThreadTimelinePageRequest } from "../../../src/services/threads/ti import { buildThreadTimelineCacheKey, createThreadTimelineCache, + isThreadTimelineResponseCacheable, type ThreadTimelineCacheKeyArgs, } from "../../../src/services/threads/timeline-cache.js"; @@ -91,6 +92,17 @@ describe("createThreadTimelineCache", () => { expect(cache.size).toBe(0); }); + it("does not cache actively running revisions even when rows are bounded", () => { + const cache = createThreadTimelineCache(); + const build = vi.fn(() => makeResponse(3)); + + cache.getOrBuild("active-10", build, { cacheable: false }); + cache.getOrBuild("active-10", build, { cacheable: false }); + + expect(build).toHaveBeenCalledTimes(2); + expect(cache.size).toBe(0); + }); + it("evicts least-recently-used entries beyond maxEntries", () => { const cache = createThreadTimelineCache({ maxEntries: 2 }); const build = vi.fn(() => makeResponse(1)); @@ -108,6 +120,16 @@ describe("createThreadTimelineCache", () => { }); }); +describe("isThreadTimelineResponseCacheable", () => { + it("caches settled statuses and skips every running status", () => { + expect(isThreadTimelineResponseCacheable("idle")).toBe(true); + expect(isThreadTimelineResponseCacheable("error")).toBe(true); + expect(isThreadTimelineResponseCacheable("starting")).toBe(false); + expect(isThreadTimelineResponseCacheable("active")).toBe(false); + expect(isThreadTimelineResponseCacheable("stopping")).toBe(false); + }); +}); + describe("buildThreadTimelineCacheKey", () => { it("is stable for identical inputs", () => { expect(buildThreadTimelineCacheKey(baseKeyArgs)).toBe( diff --git a/apps/server/test/services/threads/timeline-output-truncation.test.ts b/apps/server/test/services/threads/timeline-output-truncation.test.ts index fc55a3bd27..68cb78d199 100644 --- a/apps/server/test/services/threads/timeline-output-truncation.test.ts +++ b/apps/server/test/services/threads/timeline-output-truncation.test.ts @@ -81,6 +81,8 @@ describe("truncateTimelineResponseOutputs", () => { const turn: TimelineRow = { ...base, kind: "turn", + detailContextItemIds: [], + detailParentToolCallId: null, turnId: "turn_1", status: "completed", summaryCount: 1, diff --git a/apps/server/test/services/threads/timeline-pagination.test.ts b/apps/server/test/services/threads/timeline-pagination.test.ts index 49ba27a213..4142a3776c 100644 --- a/apps/server/test/services/threads/timeline-pagination.test.ts +++ b/apps/server/test/services/threads/timeline-pagination.test.ts @@ -3,7 +3,12 @@ import type { TimelineRow, TimelineUserConversationRow, } from "@bb/server-contract"; -import { paginateTimelineRows } from "../../../src/services/threads/timeline-pagination.js"; +import { + createTimelineEventWindowCursor, + getTimelineEventWindowCursorPayload, + hashTimelineTurnDetailsContextItemIds, + paginateTimelineRows, +} from "../../../src/services/threads/timeline-pagination.js"; function userRow(args: { id: string; @@ -32,6 +37,41 @@ function userRow(args: { } describe("paginateTimelineRows", () => { + it("writes canonical context item hashes into v2 turn-detail cursors", () => { + const contextItemIdsHash = hashTimelineTurnDetailsContextItemIds([ + "context-b", + "context-a", + "context-a", + ]); + expect(contextItemIdsHash).toHaveLength(43); + expect(contextItemIdsHash).toBe( + hashTimelineTurnDetailsContextItemIds(["context-a", "context-b"]), + ); + + const cursor = createTimelineEventWindowCursor({ + byteTarget: 1_024, + eventId: "event-1", + issuedBeforeSequence: 3, + rowLimit: 10, + scope: { + kind: "turn-details", + contextItemIdsHash, + parentToolCallId: null, + sourceSeqEnd: 2, + sourceSeqStart: 1, + threadId: "thread-1", + turnId: "turn-1", + }, + selectionStart: 1, + sequence: 2, + }); + + expect(getTimelineEventWindowCursorPayload(cursor)).toMatchObject({ + scope: { contextItemIdsHash, kind: "turn-details" }, + version: 2, + }); + }); + it("keeps grouped user rows from one request in the same segment", () => { const rows: TimelineRow[] = [ userRow({ @@ -56,10 +96,14 @@ describe("paginateTimelineRows", () => { }), ]; - const page = paginateTimelineRows(rows, { - kind: "latest", - segmentLimit: 2, - }); + const page = paginateTimelineRows( + rows, + { + kind: "latest", + segmentLimit: 2, + }, + { eventWindowOlderCursor: null }, + ); expect(page.rows.map((row) => row.id)).toEqual([ "thread-1:user-seed:2", diff --git a/apps/server/test/services/threads/timeline-parented-pagination.test.ts b/apps/server/test/services/threads/timeline-parented-pagination.test.ts index 2c19460bbd..412fd244e2 100644 --- a/apps/server/test/services/threads/timeline-parented-pagination.test.ts +++ b/apps/server/test/services/threads/timeline-parented-pagination.test.ts @@ -15,8 +15,21 @@ import { upsertHost, } from "@bb/db"; import type { DbConnection } from "@bb/db"; -import type { TimelineRow } from "@bb/server-contract"; -import { buildThreadTimeline } from "../../../src/services/threads/timeline.js"; +import type { + TimelinePaginationCursor, + TimelineRow, +} from "@bb/server-contract"; +import { + buildThreadTimeline, + buildThreadTimelineWithProfile, + buildTimelineDelegationChildrenDetails, + buildTimelineTurnSummaryDetails, + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT, + THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, +} from "../../../src/services/threads/timeline.js"; +import { THREAD_TIMELINE_PAGE_ROW_LIMIT } from "../../../src/services/threads/timeline-pagination.js"; const providerThreadId = "provider-root"; @@ -33,7 +46,7 @@ interface SetupResult { thread: Thread; } -function setup(): SetupResult { +function setup(status: Thread["status"] = "starting"): SetupResult { const db = createConnection(":memory:"); migrate(db); const host = upsertHost(db, noopNotifier, { @@ -47,6 +60,7 @@ function setup(): SetupResult { const thread = createThread(db, noopNotifier, { projectId: project.id, providerId: "claude-code", + status, }); return { db, thread }; } @@ -313,6 +327,89 @@ function rowTexts(rows: readonly TimelineRow[]): string[] { ); } +type DelegationRow = Extract< + TimelineRow, + { kind: "work"; workKind: "delegation" } +>; +type TurnRow = Extract; + +function loadDelegationChildSummaries( + db: DbConnection, + thread: Thread, + delegation: DelegationRow, +): TurnRow[] { + const childPage = delegation.childPage; + if (!childPage) return []; + const rows: TurnRow[] = []; + for (const interval of childPage.intervals) { + const intervalRows: TurnRow[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + do { + const details = buildTimelineDelegationChildrenDetails(db, thread, { + beforeCursor, + directTurnSourceSeqEnd: interval.directTurnSourceSeqEnd, + directTurnSourceSeqStart: interval.directTurnSourceSeqStart, + parentToolCallId: childPage.parentToolCallId, + sourceSeqEnd: childPage.sourceSeqEnd, + sourceSeqStart: childPage.sourceSeqStart, + turnId: childPage.ownerTurnId, + }); + intervalRows.unshift( + ...details.rows.filter((row): row is TurnRow => row.kind === "turn"), + ); + beforeCursor = details.timelinePage.olderCursor; + } while (beforeCursor !== null); + rows.push(...intervalRows); + } + return rows; +} + +function loadTurnDetailRows( + db: DbConnection, + thread: Thread, + summary: TurnRow, +): TimelineRow[] { + const rows: TimelineRow[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + do { + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: summary.detailContextItemIds, + includeProviderUnhandledOperations: false, + parentToolCallId: summary.detailParentToolCallId, + sourceSeqEnd: summary.sourceSeqEnd, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }); + rows.unshift(...details.rows); + beforeCursor = details.timelinePage.olderCursor; + } while (beforeCursor !== null); + return rows; +} + +function loadDelegationTreeRows( + db: DbConnection, + thread: Thread, + delegation: DelegationRow, + visited = new Set(), +): TimelineRow[] { + if (visited.has(delegation.callId)) return []; + visited.add(delegation.callId); + const rows: TimelineRow[] = []; + for (const summary of loadDelegationChildSummaries(db, thread, delegation)) { + rows.push(summary); + const detailRows = loadTurnDetailRows(db, thread, summary); + rows.push(...detailRows); + for (const nested of flattenRows(detailRows).filter( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + )) { + rows.push(...loadDelegationTreeRows(db, thread, nested, visited)); + } + } + return rows; +} + describe("thread timeline parented pagination", () => { it("keeps child-only subagent output off the latest page", () => { const { db, thread } = setup(); @@ -329,7 +426,7 @@ describe("thread timeline parented pagination", () => { expect(rowTexts(timeline.rows)).not.toContain("SECOND_SUBAGENT_OUTPUT"); }); - it("shows cross-window subagent output under the original Agent row", () => { + it("loads cross-window subagent output through the original Agent boundary", () => { const { db, thread } = setup(); insertCrossWindowSubagentEvents(db, thread); @@ -357,8 +454,2397 @@ describe("thread timeline parented pagination", () => { expect(delegation).toBeDefined(); expect(delegation?.callId).toBe("toolu_agent_1"); - expect(rowTexts(delegation?.childRows ?? [])).toContain( + expect(delegation?.childRows).toEqual([]); + expect(delegation?.childPage).toMatchObject({ + intervals: [ + { + beforeChildRowId: null, + directTurnSourceSeqStart: 50, + directTurnSourceSeqEnd: 51, + }, + ], + parentToolCallId: "toolu_agent_1", + sourceSeqStart: 50, + sourceSeqEnd: 51, + }); + if (!delegation) throw new Error("Expected delegation boundary"); + const childSummary = loadDelegationChildSummaries( + db, + thread, + delegation, + )[0]; + expect(childSummary).toMatchObject({ + detailParentToolCallId: "toolu_agent_1", + turnId: "child-turn", + }); + if (!childSummary) throw new Error("Expected child summary"); + expect(rowTexts(loadTurnDetailRows(db, thread, childSummary))).toContain( "SECOND_SUBAGENT_OUTPUT", ); }); + + it("plans only nonempty delegated-turn gaps around alternating retained work", () => { + const { db, thread } = setup(); + insertCrossWindowSubagentEvents(db, thread); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 6, + type: "item/completed", + scope: turnScope("parent-turn"), + providerThreadId, + itemId: "retained-one", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "retained-one", + text: "Retained one", + parentToolCallId: "toolu_agent_1", + }, + }), + }, + { + threadId: thread.id, + sequence: 7, + type: "turn/started", + scope: turnScope("alternating-child-one"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ parentToolCallId: "toolu_agent_1" }), + }, + { + threadId: thread.id, + sequence: 8, + type: "item/completed", + scope: turnScope("parent-turn"), + providerThreadId, + itemId: "retained-two", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "retained-two", + text: "Retained two", + parentToolCallId: "toolu_agent_1", + }, + }), + }, + { + threadId: thread.id, + sequence: 9, + type: "turn/started", + scope: turnScope("alternating-child-two"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ parentToolCallId: "toolu_agent_1" }), + }, + { + threadId: thread.id, + sequence: 10, + type: "item/completed", + scope: turnScope("parent-turn"), + providerThreadId, + itemId: "retained-three", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "retained-three", + text: "Retained three", + parentToolCallId: "toolu_agent_1", + }, + }), + }, + ]); + + const timeline = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + includeNestedRows: true, + maxSeq: 10, + page: { kind: "latest", segmentLimit: 20 }, + }); + const delegation = flattenRows(timeline.rows).find( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ); + if (!delegation?.childPage) { + throw new Error("Expected alternating delegation child plan"); + } + expect(delegation.childRows.map((row) => row.sourceSeqStart)).toEqual([ + 6, 8, 10, + ]); + expect(delegation.childPage).toMatchObject({ + sourceSeqEnd: 9, + sourceSeqStart: 7, + }); + expect(delegation.childPage.intervals).toEqual([ + { + beforeChildRowId: delegation.childRows[1]?.id, + directTurnSourceSeqEnd: 7, + directTurnSourceSeqStart: 7, + }, + { + beforeChildRowId: delegation.childRows[2]?.id, + directTurnSourceSeqEnd: 9, + directTurnSourceSeqStart: 9, + }, + ]); + expect(delegation.childPage.intervals.length).toBeLessThanOrEqual( + new Set(delegation.childRows.map((row) => row.sourceSeqStart)).size + 1, + ); + + const orderedSourceSeqs: number[] = []; + for (const childRow of delegation.childRows) { + const interval = delegation.childPage.intervals.find( + (candidate) => candidate.beforeChildRowId === childRow.id, + ); + if (interval) { + orderedSourceSeqs.push( + ...buildTimelineDelegationChildrenDetails(db, thread, { + beforeCursor: null, + directTurnSourceSeqEnd: interval.directTurnSourceSeqEnd, + directTurnSourceSeqStart: interval.directTurnSourceSeqStart, + parentToolCallId: delegation.childPage.parentToolCallId, + sourceSeqEnd: delegation.childPage.sourceSeqEnd, + sourceSeqStart: delegation.childPage.sourceSeqStart, + turnId: delegation.childPage.ownerTurnId, + }).rows.map((row) => row.sourceSeqStart), + ); + } + orderedSourceSeqs.push(childRow.sourceSeqStart); + } + expect(orderedSourceSeqs).toEqual([6, 7, 8, 9, 10]); + expect(new Set(orderedSourceSeqs).size).toBe(orderedSourceSeqs.length); + }); + + it("pages one thousand direct child turns exactly once", () => { + const { db, thread } = setup(); + insertCrossWindowSubagentEvents(db, thread); + const childTurnCount = 1_000; + insertEvents( + db, + noopNotifier, + Array.from({ length: childTurnCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 52, + type: "turn/started" as const, + scope: turnScope(`wide-child-${index}`), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ parentToolCallId: "toolu_agent_1" }), + })), + ); + const timeline = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + includeNestedRows: true, + maxSeq: childTurnCount + 51, + page: { + kind: "older", + beforeCursor: { + anchorId: `${thread.id}:user-seed:20`, + anchorSeq: 20, + }, + segmentLimit: 1, + }, + }); + const delegation = flattenRows(timeline.rows).find( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ); + if (!delegation?.childPage) { + throw new Error("Expected delegation child page"); + } + const childPage = delegation.childPage; + + const seenTurnIds: string[] = []; + const interval = childPage.intervals[0]; + if (!interval) throw new Error("Expected delegation child interval"); + let beforeCursor: TimelinePaginationCursor | null = null; + let previousCursorSequence = Number.POSITIVE_INFINITY; + do { + const page = buildTimelineDelegationChildrenDetails(db, thread, { + beforeCursor, + directTurnSourceSeqEnd: interval.directTurnSourceSeqEnd, + directTurnSourceSeqStart: interval.directTurnSourceSeqStart, + parentToolCallId: childPage.parentToolCallId, + sourceSeqEnd: childPage.sourceSeqEnd, + sourceSeqStart: childPage.sourceSeqStart, + turnId: childPage.ownerTurnId, + }); + expect(page.rows.length).toBeLessThanOrEqual(50); + for (const row of page.rows) { + expect(row).toMatchObject({ + kind: "turn", + detailParentToolCallId: "toolu_agent_1", + }); + if (row.kind === "turn") seenTurnIds.push(row.turnId); + } + beforeCursor = page.timelinePage.olderCursor; + if (beforeCursor) { + expect(beforeCursor.anchorSeq).toBeLessThan(previousCursorSequence); + previousCursorSequence = beforeCursor.anchorSeq; + } + } while (beforeCursor !== null); + + expect(seenTurnIds).toHaveLength(childTurnCount + 1); + expect(new Set(seenTurnIds).size).toBe(childTurnCount + 1); + expect(seenTurnIds).toContain("child-turn"); + expect(seenTurnIds).toContain("wide-child-0"); + expect(seenTurnIds).toContain(`wide-child-${childTurnCount - 1}`); + expect(childPage.intervals).toHaveLength(1); + + const firstIntervalPage = buildTimelineDelegationChildrenDetails( + db, + thread, + { + beforeCursor: null, + directTurnSourceSeqEnd: 600, + directTurnSourceSeqStart: childPage.sourceSeqStart, + parentToolCallId: childPage.parentToolCallId, + sourceSeqEnd: childPage.sourceSeqEnd, + sourceSeqStart: childPage.sourceSeqStart, + turnId: childPage.ownerTurnId, + }, + ); + const intervalCursor = firstIntervalPage.timelinePage.olderCursor; + if (!intervalCursor) throw new Error("Expected interval-scoped cursor"); + expect(() => + buildTimelineDelegationChildrenDetails(db, thread, { + beforeCursor: intervalCursor, + directTurnSourceSeqEnd: childPage.sourceSeqEnd, + directTurnSourceSeqStart: 601, + parentToolCallId: childPage.parentToolCallId, + sourceSeqEnd: childPage.sourceSeqEnd, + sourceSeqStart: childPage.sourceSeqStart, + turnId: childPage.ownerTurnId, + }), + ).toThrow("cursor is no longer available"); + }); + + it("bounds cross-window descendant enrichment before projection", () => { + const { db, thread } = setup(); + insertCrossWindowSubagentEvents(db, thread); + const descendantCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 50; + insertEvents( + db, + noopNotifier, + Array.from({ length: descendantCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 52, + type: "item/completed" as const, + scope: turnScope("child-turn"), + providerThreadId, + itemId: `extra-child-message-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `extra-child-message-${index}`, + text: `Extra child output ${index}`, + parentToolCallId: "toolu_agent_1", + }, + }), + })), + ); + + const { profile, response } = buildThreadTimelineWithProfile(db, thread, { + includeProviderUnhandledOperations: false, + includeNestedRows: true, + maxSeq: descendantCount + 51, + page: { + kind: "older", + beforeCursor: { + anchorId: `${thread.id}:user-seed:20`, + anchorSeq: 20, + }, + segmentLimit: 1, + }, + }); + + expect(profile.eventRowCount).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT + + 10, + ); + const delegation = flattenRows(response.rows).find( + ( + row, + ): row is Extract< + TimelineRow, + { kind: "work"; workKind: "delegation" } + > => row.kind === "work" && row.workKind === "delegation", + ); + expect(delegation).toBeDefined(); + if (!delegation) throw new Error("Expected delegation boundary"); + expect(delegation.childRows).toEqual([]); + const omittedChildTurn = loadDelegationChildSummaries( + db, + thread, + delegation, + )[0]; + expect(omittedChildTurn).toMatchObject({ + sourceSeqStart: 50, + sourceSeqEnd: descendantCount + 51, + summaryCount: descendantCount + 2, + detailParentToolCallId: "toolu_agent_1", + }); + if (!omittedChildTurn) { + throw new Error("Expected omitted child work summary"); + } + + const recoveredChildMessages = new Set(); + for (const text of rowTexts( + loadTurnDetailRows(db, thread, omittedChildTurn), + )) { + recoveredChildMessages.add(text); + } + + expect(recoveredChildMessages.size).toBe(descendantCount + 1); + expect(recoveredChildMessages).toContain("SECOND_SUBAGENT_OUTPUT"); + expect(recoveredChildMessages).toContain("Extra child output 0"); + expect(recoveredChildMessages).toContain( + `Extra child output ${descendantCount - 1}`, + ); + }); + + it("propagates late recursive descendants through budget-omitted delegations", () => { + const { db, thread } = setup(); + insertCrossWindowSubagentEvents(db, thread); + const interveningMessageCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 50; + const grandchildStartSequence = 55 + interveningMessageCount; + const grandchildInterveningMessageCount = + THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 50; + const greatGrandchildStartSequence = + grandchildStartSequence + 4 + grandchildInterveningMessageCount; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 52, + type: "item/started", + scope: turnScope("child-turn"), + providerThreadId, + itemId: "nested-agent", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "nested-agent", + tool: "Agent", + arguments: { + prompt: "Run the grandchild.", + subagent_type: "general-purpose", + }, + parentToolCallId: "toolu_agent_1", + status: "pending", + }, + }), + }, + { + threadId: thread.id, + sequence: 53, + type: "item/completed", + scope: turnScope("child-turn"), + providerThreadId, + itemId: "nested-agent", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "nested-agent", + tool: "Agent", + arguments: { + prompt: "Run the grandchild.", + subagent_type: "general-purpose", + }, + parentToolCallId: "toolu_agent_1", + result: "nested launched", + status: "completed", + }, + }), + }, + ...Array.from({ length: interveningMessageCount }, (_, index) => ({ + threadId: thread.id, + sequence: 54 + index, + type: "item/completed" as const, + scope: turnScope("child-turn"), + providerThreadId, + itemId: `intervening-child-message-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `intervening-child-message-${index}`, + text: `Intervening child output ${index}`, + parentToolCallId: "toolu_agent_1", + }, + }), + })), + { + threadId: thread.id, + sequence: grandchildStartSequence - 1, + type: "turn/completed", + scope: turnScope("child-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ + parentToolCallId: "toolu_agent_1", + status: "completed", + }), + }, + { + threadId: thread.id, + sequence: grandchildStartSequence, + type: "turn/started", + scope: turnScope("grandchild-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ parentToolCallId: "nested-agent" }), + }, + { + threadId: thread.id, + sequence: grandchildStartSequence + 1, + type: "item/started", + scope: turnScope("grandchild-turn"), + providerThreadId, + itemId: "great-grandchild-agent", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "great-grandchild-agent", + tool: "Agent", + arguments: { + prompt: "Run the great grandchild.", + subagent_type: "general-purpose", + }, + parentToolCallId: "nested-agent", + status: "pending", + }, + }), + }, + { + threadId: thread.id, + sequence: grandchildStartSequence + 2, + type: "item/completed", + scope: turnScope("grandchild-turn"), + providerThreadId, + itemId: "great-grandchild-agent", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "great-grandchild-agent", + tool: "Agent", + arguments: { + prompt: "Run the great grandchild.", + subagent_type: "general-purpose", + }, + parentToolCallId: "nested-agent", + result: "great grandchild launched", + status: "completed", + }, + }), + }, + ...Array.from( + { length: grandchildInterveningMessageCount }, + (_, index) => ({ + threadId: thread.id, + sequence: grandchildStartSequence + 3 + index, + type: "item/completed" as const, + scope: turnScope("grandchild-turn"), + providerThreadId, + itemId: `intervening-grandchild-message-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `intervening-grandchild-message-${index}`, + text: `Intervening grandchild output ${index}`, + parentToolCallId: "nested-agent", + }, + }), + }), + ), + { + threadId: thread.id, + sequence: greatGrandchildStartSequence - 1, + type: "turn/completed", + scope: turnScope("grandchild-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ + parentToolCallId: "nested-agent", + status: "completed", + }), + }, + { + threadId: thread.id, + sequence: greatGrandchildStartSequence, + type: "turn/started", + scope: turnScope("great-grandchild-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ + parentToolCallId: "great-grandchild-agent", + }), + }, + { + threadId: thread.id, + sequence: greatGrandchildStartSequence + 1, + type: "item/completed", + scope: turnScope("great-grandchild-turn"), + providerThreadId, + itemId: "great-grandchild-message", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "great-grandchild-message", + text: "GREAT_GRANDCHILD_LATE_OUTPUT", + parentToolCallId: "great-grandchild-agent", + }, + }), + }, + ]); + + const { profile, response: timeline } = buildThreadTimelineWithProfile( + db, + thread, + { + includeProviderUnhandledOperations: false, + includeNestedRows: true, + maxSeq: greatGrandchildStartSequence + 1, + page: { + kind: "older", + beforeCursor: { + anchorId: `${thread.id}:user-seed:20`, + anchorSeq: 20, + }, + segmentLimit: 1, + }, + }, + ); + expect(profile.eventRowCount).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT + + 10, + ); + const rootDelegation = flattenRows(timeline.rows).find( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ); + expect(rootDelegation?.childPage?.sourceSeqEnd).toBe( + greatGrandchildStartSequence + 1, + ); + if (!rootDelegation) throw new Error("Expected root delegation boundary"); + + const childSummary = loadDelegationChildSummaries( + db, + thread, + rootDelegation, + ).find((row) => row.turnId === "child-turn"); + expect(childSummary).toMatchObject({ + detailParentToolCallId: "toolu_agent_1", + sourceSeqEnd: greatGrandchildStartSequence + 1, + }); + if (!childSummary) throw new Error("Expected child work summary"); + + const delegatedTreeRows = loadDelegationTreeRows( + db, + thread, + rootDelegation, + ); + expect(rowTexts(delegatedTreeRows)).toContain( + "GREAT_GRANDCHILD_LATE_OUTPUT", + ); + const nestedBoundaries = flattenRows(delegatedTreeRows).filter( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ); + expect(nestedBoundaries.map((row) => row.callId)).toEqual( + expect.arrayContaining(["nested-agent", "great-grandchild-agent"]), + ); + }); + + it("restores a running child command lifecycle inside bounded enrichment", () => { + const { db, thread } = setup("active"); + insertCrossWindowSubagentEvents(db, thread); + const finalizedMessageCount = + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT + 20; + const deltaCount = THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT + 40; + const commandStartSequence = 52 + finalizedMessageCount; + const largeDeltaPadding = "x".repeat(4_000); + insertEvents(db, noopNotifier, [ + ...Array.from({ length: finalizedMessageCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 52, + type: "item/completed" as const, + scope: turnScope("child-turn"), + providerThreadId, + itemId: `finalized-child-message-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `finalized-child-message-${index}`, + text: `Finalized child message ${index}`, + parentToolCallId: "toolu_agent_1", + }, + }), + })), + { + threadId: thread.id, + sequence: commandStartSequence, + type: "item/started", + scope: turnScope("child-turn"), + providerThreadId, + itemId: "nested-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "nested-command", + command: "nested-long-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + parentToolCallId: "toolu_agent_1", + }, + }), + }, + ...Array.from({ length: deltaCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + commandStartSequence + 1, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("child-turn"), + providerThreadId, + itemId: "nested-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "nested-command", + delta: `nested chunk ${index} ${largeDeltaPadding}\n`, + parentToolCallId: "toolu_agent_1", + }), + })), + { + threadId: thread.id, + sequence: commandStartSequence + deltaCount + 1, + type: "item/completed", + scope: turnScope("child-turn"), + providerThreadId, + itemId: "late-finalized-child-message", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "late-finalized-child-message", + text: "Finalized after pending command", + parentToolCallId: "toolu_agent_1", + }, + }), + }, + { + threadId: thread.id, + sequence: commandStartSequence + deltaCount + 2, + type: "thread/contextWindowUsage/updated", + scope: turnScope("parent-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ + accountingPadding: "x".repeat(1_000), + contextWindowUsage: { + estimated: false, + modelContextWindow: 200_000, + usedTokens: 120_000, + }, + }), + }, + ]); + + const { profile, response: timeline } = buildThreadTimelineWithProfile( + db, + thread, + { + includeProviderUnhandledOperations: false, + includeNestedRows: true, + maxSeq: commandStartSequence + deltaCount + 2, + page: { + kind: "older", + beforeCursor: { + anchorId: `${thread.id}:user-seed:20`, + anchorSeq: 20, + }, + segmentLimit: 1, + }, + }, + ); + expect(profile.enrichmentEventBytes).toBeGreaterThan( + THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET - 32_000, + ); + expect(profile.enrichmentEventBytes).toBeLessThanOrEqual( + THREAD_TIMELINE_PARENTED_ENRICHMENT_BYTE_TARGET, + ); + expect(profile.enrichmentEventRowCount).toBeLessThanOrEqual( + THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT, + ); + expect(profile.contextWindowEventRowCount).toBe(1); + expect(profile.contextWindowEventDataBytes).toBeGreaterThan(0); + const delegation = flattenRows(timeline.rows).find( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ); + expect(delegation?.childRows).toEqual([]); + if (!delegation) throw new Error("Expected delegation boundary"); + + const delegatedRows = loadDelegationTreeRows(db, thread, delegation); + const nestedCommands = flattenRows(delegatedRows).filter( + ( + row, + ): row is Extract => + row.kind === "work" && + row.workKind === "command" && + row.callId === "nested-command", + ); + expect(nestedCommands).toHaveLength(1); + expect(nestedCommands[0]).toMatchObject({ + command: "nested-long-command", + status: "pending", + }); + expect(nestedCommands[0]?.output).toContain( + `nested chunk ${deltaCount - 1}`, + ); + + const recoveredFinalizedMessages = new Set(rowTexts(delegatedRows)); + expect(recoveredFinalizedMessages.size).toBe(finalizedMessageCount + 2); + expect(recoveredFinalizedMessages).toContain("SECOND_SUBAGENT_OUTPUT"); + expect(recoveredFinalizedMessages).toContain( + `Finalized child message ${finalizedMessageCount - 1}`, + ); + expect(recoveredFinalizedMessages).toContain( + "Finalized after pending command", + ); + }); +}); + +describe("thread timeline active-turn pagination", () => { + it("does not restore a reused item start from after the captured detail range", () => { + const { db, thread } = setup("active"); + const outputCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 46; + const completedSequence = outputCount + 3; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "turn/started", + scope: turnScope("reused-item-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 2, + type: "item/started", + scope: turnScope("reused-item-turn"), + providerThreadId, + itemId: "reused-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "reused-command", + command: "captured-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: outputCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 3, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("reused-item-turn"), + providerThreadId, + itemId: "reused-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "reused-command", + delta: `captured ${index}\n`, + }), + })), + { + threadId: thread.id, + sequence: completedSequence, + type: "item/completed", + scope: turnScope("reused-item-turn"), + providerThreadId, + itemId: "reused-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "reused-command", + command: "captured-command", + cwd: "/repo", + status: "completed", + approvalStatus: null, + aggregatedOutput: "captured done", + exitCode: 0, + }, + }), + }, + { + threadId: thread.id, + sequence: completedSequence + 1, + type: "item/started", + scope: turnScope("reused-item-turn"), + providerThreadId, + itemId: "reused-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "reused-command", + command: "future-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ]); + + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: null, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd: completedSequence, + sourceSeqStart: 1, + turnId: "reused-item-turn", + }); + const commands = flattenRows(details.rows).filter( + ( + row, + ): row is Extract => + row.kind === "work" && row.workKind === "command", + ); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + command: "captured-command", + sourceSeqEnd: completedSequence, + status: "completed", + }); + expect(JSON.stringify(details.rows)).not.toContain("future-command"); + }); + + it("owns a completed long command once across top-level pages and details", () => { + const { db, thread } = setup("idle"); + const clientRequestId = requestId(1); + const outputCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT * 2 + 40; + const completedSequence = outputCount + 5; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run then finish.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: "completed-long-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "completed-long-command", + command: "long-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: outputCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 5, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: "completed-long-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "completed-long-command", + delta: `chunk ${index}\n`, + }), + })), + { + threadId: thread.id, + sequence: completedSequence, + type: "item/completed", + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: "completed-long-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "completed-long-command", + command: "long-command", + cwd: "/repo", + status: "completed", + approvalStatus: null, + aggregatedOutput: "finished", + exitCode: 0, + }, + }), + }, + { + threadId: thread.id, + sequence: completedSequence + 1, + type: "turn/completed", + scope: turnScope("completed-command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ status: "completed" }), + }, + ]); + + const topLevelCommands: Extract< + TimelineRow, + { kind: "work"; workKind: "command" } + >[] = []; + let page = buildThreadTimeline(db, thread, { + includeNestedRows: false, + includeProviderUnhandledOperations: false, + maxSeq: completedSequence + 1, + page: { kind: "latest", segmentLimit: 20 }, + }); + const completedSummary = page.rows.find( + (row): row is Extract => + row.kind === "turn" && row.turnId === "completed-command-turn", + ); + const firstTopLevelCursor = page.timelinePage.olderCursor; + if (!firstTopLevelCursor) { + throw new Error("Expected a raw-event top-level cursor"); + } + expect(() => + buildThreadTimeline(db, thread, { + includeNestedRows: false, + includeProviderUnhandledOperations: false, + maxSeq: completedSequence + 1, + page: { + beforeCursor: firstTopLevelCursor, + kind: "older", + segmentLimit: 19, + }, + }), + ).toThrow("cursor is no longer available"); + while (true) { + topLevelCommands.push( + ...flattenRows(page.rows).filter( + ( + row, + ): row is Extract< + TimelineRow, + { kind: "work"; workKind: "command" } + > => row.kind === "work" && row.workKind === "command", + ), + ); + const cursor = page.timelinePage.olderCursor; + if (!cursor) break; + page = buildThreadTimeline(db, thread, { + includeNestedRows: false, + includeProviderUnhandledOperations: false, + maxSeq: completedSequence + 1, + page: { beforeCursor: cursor, kind: "older", segmentLimit: 20 }, + }); + } + expect(topLevelCommands).toHaveLength(0); + if (!completedSummary) { + throw new Error("Expected completed command summary"); + } + + const detailCommands: Extract< + TimelineRow, + { kind: "work"; workKind: "command" } + >[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + do { + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd: completedSummary.sourceSeqEnd, + sourceSeqStart: completedSummary.sourceSeqStart, + turnId: completedSummary.turnId, + }); + detailCommands.push( + ...flattenRows(details.rows).filter( + ( + row, + ): row is Extract< + TimelineRow, + { kind: "work"; workKind: "command" } + > => row.kind === "work" && row.workKind === "command", + ), + ); + beforeCursor = details.timelinePage.olderCursor; + } while (beforeCursor !== null); + expect(detailCommands).toHaveLength(1); + expect(detailCommands[0]).toMatchObject({ + callId: "completed-long-command", + status: "completed", + }); + }); + + it("restores a pending command start before a long output-delta window", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + const outputCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT * 2 + 40; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run a long command.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ + clientRequestId, + ignoredLargeField: "x".repeat( + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET + 50_000, + ), + }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("command-turn"), + providerThreadId, + itemId: "command-1", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "command-1", + command: "long-running-command", + cwd: "/repo", + aggregatedOutput: "", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: outputCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 5, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("command-turn"), + providerThreadId, + itemId: "command-1", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "command-1", + delta: `chunk ${index}\n`, + }), + })), + ]); + + const { profile, response: latest } = buildThreadTimelineWithProfile( + db, + thread, + { + includeProviderUnhandledOperations: false, + maxSeq: outputCount + 4, + page: { kind: "latest", segmentLimit: 20 }, + }, + ); + expect(profile.eventRowCount).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 10, + ); + const pendingCommands = latest.rows.filter( + ( + row, + ): row is Extract => + row.kind === "work" && + row.workKind === "command" && + row.status === "pending", + ); + expect(pendingCommands).toHaveLength(1); + expect(pendingCommands[0]?.command).toBe("long-running-command"); + expect(pendingCommands[0]?.output).toContain(`chunk ${outputCount - 1}`); + + const summary = latest.rows.find( + (row): row is Extract => + row.kind === "turn", + ); + if (!summary) { + throw new Error("Expected command history summary"); + } + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: null, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd: summary.sourceSeqEnd, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }); + expect( + details.rows.filter( + (row) => row.kind === "work" && row.workKind === "command", + ), + ).toHaveLength(0); + + const commandIdsAcrossPages: string[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + let detailPageCount = 0; + do { + const page = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd: outputCount + 4, + sourceSeqStart: 2, + turnId: "command-turn", + }); + commandIdsAcrossPages.push( + ...flattenRows(page.rows).flatMap((row) => + row.kind === "work" && row.workKind === "command" ? [row.callId] : [], + ), + ); + beforeCursor = page.timelinePage.olderCursor; + detailPageCount++; + if (detailPageCount > 10) { + throw new Error("Detail pagination did not terminate"); + } + } while (beforeCursor !== null); + + expect(detailPageCount).toBeGreaterThan(2); + expect( + commandIdsAcrossPages.filter((callId) => callId === "command-1"), + ).toHaveLength(1); + }); + + it("bounds a single oversized output event before projection", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run a huge command.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("command-turn"), + providerThreadId, + itemId: "huge-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "huge-command", + command: "huge-output-command", + cwd: "/repo", + aggregatedOutput: "", + status: "pending", + approvalStatus: null, + }, + }), + }, + { + threadId: thread.id, + sequence: 5, + type: "item/commandExecution/outputDelta", + scope: turnScope("command-turn"), + providerThreadId, + itemId: "huge-command", + itemKind: "commandExecution", + data: JSON.stringify({ + itemId: "huge-command", + delta: "x".repeat(THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET + 50_000), + }), + }, + ]); + + const { profile, response } = buildThreadTimelineWithProfile(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 5, + page: { kind: "latest", segmentLimit: 20 }, + }); + + expect(profile.eventDataBytes).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + ); + const command = flattenRows(response.rows).find( + ( + row, + ): row is Extract => + row.kind === "work" && + row.workKind === "command" && + row.callId === "huge-command", + ); + expect(command?.output).toContain( + "oversized event truncated for timeline rendering", + ); + expect(command?.output.length).toBeLessThan(40_000); + }); + + it("paginates an aggregate byte cutoff without materializing the next row", () => { + const { db, thread } = setup("idle"); + const clientRequestId = requestId(1); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run bounded output.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("aggregate-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: "{}", + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("aggregate-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("aggregate-turn"), + providerThreadId, + itemId: "aggregate-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "aggregate-command", + command: "aggregate-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: 4 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 5, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("aggregate-turn"), + providerThreadId, + itemId: "aggregate-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "aggregate-command", + delta: `${index}:${"x".repeat(299_000)}`, + }), + })), + { + threadId: thread.id, + sequence: 9, + type: "turn/completed", + scope: turnScope("aggregate-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ status: "completed" }), + }, + ]); + + const latest = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 9, + page: { kind: "latest", segmentLimit: 20 }, + }); + const cursor = latest.timelinePage.olderCursor; + expect(cursor?.anchorSeq).toBe(7); + expect(latest.timelinePage.hasOlderRows).toBe(true); + if (!cursor) throw new Error("Expected aggregate byte cursor"); + + const older = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 9, + page: { beforeCursor: cursor, kind: "older", segmentLimit: 20 }, + }); + expect(older.timelinePage.olderCursor).toBeNull(); + expect(older.timelinePage.hasOlderRows).toBe(false); + }); + + it("traverses repeated byte cutoffs and oversized identities exactly once", () => { + const { db, thread } = setup("idle"); + const clientRequestId = requestId(1); + const oversizedProviderThreadId = `provider-${"界".repeat(300_000)}`; + const oversizedTurnId = `turn-${"🧭".repeat(250_000)}`; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [ + { type: "text", text: "Walk every bounded page.", mentions: [] }, + ], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "system/error", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ message: `error-2-${"a".repeat(300_000)}` }), + }, + { + threadId: thread.id, + sequence: 3, + type: "system/error", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ message: `error-3-${"b".repeat(300_000)}` }), + }, + { + threadId: thread.id, + sequence: 4, + type: "system/error", + scope: threadScope(), + providerThreadId: oversizedProviderThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ message: "oversized provider identity" }), + }, + { + threadId: thread.id, + sequence: 5, + type: "system/error", + scope: turnScope(oversizedTurnId), + itemId: null, + itemKind: null, + data: JSON.stringify({ message: "oversized turn identity" }), + }, + { + threadId: thread.id, + sequence: 6, + type: "system/error", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ message: `error-6-${"c".repeat(300_000)}` }), + }, + { + threadId: thread.id, + sequence: 7, + type: "system/error", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ message: `error-7-${"d".repeat(300_000)}` }), + }, + ]); + + const errorSequences: number[] = []; + const oversizedIdentitySequences: number[] = []; + let exhausted = false; + let followedOlderCursor = false; + let page: + | { kind: "latest"; segmentLimit: number } + | { + beforeCursor: TimelinePaginationCursor; + kind: "older"; + segmentLimit: number; + } = { kind: "latest", segmentLimit: 20 }; + for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) { + const { profile, response } = buildThreadTimelineWithProfile(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 7, + page, + }); + expect(profile.eventDataBytes).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + ); + for (const row of flattenRows(response.rows)) { + if ( + row.kind !== "system" || + row.systemKind !== "error" || + row.sourceSeqStart < 2 || + row.sourceSeqStart > 7 + ) { + continue; + } + errorSequences.push(row.sourceSeqStart); + if ( + `${row.title}\n${row.detail ?? ""}`.includes( + "identity metadata too large to render inline", + ) + ) { + oversizedIdentitySequences.push(row.sourceSeqStart); + } + } + + const cursor = response.timelinePage.olderCursor; + if (cursor === null) { + exhausted = true; + break; + } + expect(response.timelinePage.hasOlderRows).toBe(true); + followedOlderCursor = true; + page = { beforeCursor: cursor, kind: "older", segmentLimit: 20 }; + } + + expect(exhausted).toBe(true); + expect(followedOlderCursor).toBe(true); + expect([...errorSequences].sort((left, right) => left - right)).toEqual([ + 2, 3, 4, 5, 6, 7, + ]); + expect(new Set(errorSequences).size).toBe(errorSequences.length); + expect( + [...oversizedIdentitySequences].sort((left, right) => left - right), + ).toEqual([4, 5]); + }); + + it("keeps oversized background-task enrichment bounded and schema-valid", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + const taskItem = { + type: "backgroundTask" as const, + id: "large-workflow", + taskType: "local_workflow", + description: "Large workflow", + status: "pending" as const, + taskStatus: "running" as const, + skipTranscript: false, + }; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Start a workflow.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("workflow-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("workflow-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("workflow-turn"), + providerThreadId, + itemId: taskItem.id, + itemKind: "backgroundTask", + data: JSON.stringify({ item: taskItem }), + }, + { + threadId: thread.id, + sequence: 5, + type: "item/backgroundTask/progress", + scope: threadScope(), + providerThreadId, + itemId: taskItem.id, + itemKind: "backgroundTask", + data: JSON.stringify({ + item: { + ...taskItem, + summary: "x".repeat( + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET + 50_000, + ), + }, + }), + }, + ]); + + const { profile, response } = buildThreadTimelineWithProfile(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 5, + page: { kind: "latest", segmentLimit: 20 }, + }); + + expect(profile.eventDataBytes).toBeLessThanOrEqual( + THREAD_TIMELINE_EVENT_WINDOW_BYTE_TARGET, + ); + expect(response.activeWorkflow).toMatchObject({ + itemId: taskItem.id, + status: "pending", + description: expect.stringContaining("large task details omitted"), + }); + }); + + it("does not repeat a pinned running command inside interleaved summaries", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + const interleavedWorkCount = 120; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [ + { type: "text", text: "Run interleaved work.", mentions: [] }, + ], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("interleaved-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("interleaved-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("interleaved-turn"), + providerThreadId, + itemId: "pinned-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "pinned-command", + command: "long-running-command", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ...Array.from({ length: interleavedWorkCount }, (_, index) => [ + { + threadId: thread.id, + sequence: 5 + index * 2, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("interleaved-turn"), + providerThreadId, + itemId: "pinned-command", + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: "pinned-command", + delta: `live ${index}\n`, + }), + }, + { + threadId: thread.id, + sequence: 6 + index * 2, + type: "item/completed" as const, + scope: turnScope("interleaved-turn"), + providerThreadId, + itemId: `completed-command-${index}`, + itemKind: "commandExecution" as const, + data: JSON.stringify({ + item: { + type: "commandExecution", + id: `completed-command-${index}`, + command: `completed command ${index}`, + cwd: "/repo", + status: "completed", + approvalStatus: null, + aggregatedOutput: `done ${index}`, + exitCode: 0, + }, + }), + }, + ]).flat(), + ]); + + const latest = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq: 4 + interleavedWorkCount * 2, + page: { kind: "latest", segmentLimit: 20 }, + }); + expect( + latest.rows.filter( + (row) => + row.kind === "work" && + row.workKind === "command" && + row.callId === "pinned-command", + ), + ).toHaveLength(1); + const summary = latest.rows.find( + (row): row is Extract => + row.kind === "turn", + ); + if (!summary) { + throw new Error("Expected an interleaved work summary"); + } + + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: null, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd: summary.sourceSeqEnd, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }); + expect( + flattenRows(details.rows).some( + (row) => + row.kind === "work" && + row.workKind === "command" && + row.callId === "pinned-command", + ), + ).toBe(false); + expect(flattenRows(details.rows).length).toBeGreaterThan(0); + }); + + it("emits every lifecycle exactly once when a newer page exceeds backfill capacity", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + const commandCount = THREAD_TIMELINE_PARENTED_ENRICHMENT_ROW_LIMIT + 20; + const fillerCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT - commandCount; + const firstFillerSequence = 4 + commandCount; + const firstDeltaSequence = firstFillerSequence + fillerCount; + const sourceSeqEnd = firstDeltaSequence + commandCount - 1; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run many commands.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("many-command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("many-command-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + ...Array.from({ length: commandCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 4, + type: "item/started" as const, + scope: turnScope("many-command-turn"), + providerThreadId, + itemId: `many-command-${index}`, + itemKind: "commandExecution" as const, + data: JSON.stringify({ + item: { + type: "commandExecution", + id: `many-command-${index}`, + command: `command ${index}`, + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + })), + ...Array.from({ length: fillerCount }, (_, index) => ({ + threadId: thread.id, + sequence: firstFillerSequence + index, + type: "item/completed" as const, + scope: turnScope("many-command-turn"), + providerThreadId, + itemId: `filler-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `filler-${index}`, + text: `Filler ${index}`, + }, + }), + })), + ...Array.from({ length: commandCount }, (_, index) => ({ + threadId: thread.id, + sequence: firstDeltaSequence + index, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("many-command-turn"), + providerThreadId, + itemId: `many-command-${index}`, + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: `many-command-${index}`, + delta: `output ${index}`, + }), + })), + ]); + + const commandIdsAcrossPages: string[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + do { + const page = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd, + sourceSeqStart: 2, + turnId: "many-command-turn", + }); + commandIdsAcrossPages.push( + ...flattenRows(page.rows).flatMap((row) => + row.kind === "work" && row.workKind === "command" ? [row.callId] : [], + ), + ); + beforeCursor = page.timelinePage.olderCursor; + } while (beforeCursor !== null); + + expect(commandIdsAcrossPages).toHaveLength(commandCount); + expect(new Set(commandIdsAcrossPages).size).toBe(commandCount); + }); + + it("emits nested lifecycles once when a newer page exceeds the child reserve", () => { + const { db, thread } = setup("active"); + const clientRequestId = requestId(1); + const nestedCommandCount = 20; + const fillerCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT; + const firstFillerSequence = 5 + nestedCommandCount; + const firstDeltaSequence = firstFillerSequence + fillerCount; + const sourceSeqEnd = firstDeltaSequence + nestedCommandCount - 1; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: clientRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Run nested commands.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: "nested-parent", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "nested-parent", + tool: "Agent", + arguments: { + prompt: "Run children", + subagent_type: "general-purpose", + }, + status: "pending", + }, + }), + }, + ...Array.from({ length: nestedCommandCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 5, + type: "item/started" as const, + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: `nested-many-command-${index}`, + itemKind: "commandExecution" as const, + data: JSON.stringify({ + item: { + type: "commandExecution", + id: `nested-many-command-${index}`, + command: `nested command ${index}`, + cwd: "/repo", + status: "pending", + approvalStatus: null, + parentToolCallId: "nested-parent", + }, + }), + })), + ...Array.from({ length: fillerCount }, (_, index) => ({ + threadId: thread.id, + sequence: firstFillerSequence + index, + type: "item/completed" as const, + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: `nested-filler-${index}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `nested-filler-${index}`, + text: `Nested filler ${index}`, + }, + }), + })), + ...Array.from({ length: nestedCommandCount }, (_, index) => ({ + threadId: thread.id, + sequence: firstDeltaSequence + index, + type: "item/commandExecution/outputDelta" as const, + scope: turnScope("nested-many-turn"), + providerThreadId, + itemId: `nested-many-command-${index}`, + itemKind: "commandExecution" as const, + data: JSON.stringify({ + itemId: `nested-many-command-${index}`, + delta: `nested output ${index}`, + parentToolCallId: "nested-parent", + }), + })), + ]); + + const delegationRowsAcrossPages: DelegationRow[] = []; + const commandIdsAcrossPages: string[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + do { + const page = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: [], + includeProviderUnhandledOperations: false, + sourceSeqEnd, + sourceSeqStart: 2, + turnId: "nested-many-turn", + }); + delegationRowsAcrossPages.push( + ...flattenRows(page.rows).filter( + (row): row is DelegationRow => + row.kind === "work" && row.workKind === "delegation", + ), + ); + commandIdsAcrossPages.push( + ...flattenRows(page.rows).flatMap((row) => + row.kind === "work" && row.workKind === "command" ? [row.callId] : [], + ), + ); + beforeCursor = page.timelinePage.olderCursor; + } while (beforeCursor !== null); + + expect(delegationRowsAcrossPages).toHaveLength(1); + const delegation = delegationRowsAcrossPages[0]; + if (!delegation) throw new Error("Expected nested delegation boundary"); + expect(delegation.childPage).toBeNull(); + expect(commandIdsAcrossPages).toHaveLength(nestedCommandCount); + expect(new Set(commandIdsAcrossPages).size).toBe(nestedCommandCount); + }); + + it("keeps the prompt and pages older work through the pending turn summary", () => { + const { db, thread } = setup("active"); + const olderRequestId = requestId(1); + const firstRequestId = requestId(2); + const assistantMessageCount = THREAD_TIMELINE_EVENT_WINDOW_ROW_LIMIT + 40; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "spawn", + initiator: "user", + request: { method: "thread/start", params: {} }, + requestId: olderRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Earlier question.", mentions: [] }], + target: { kind: "thread-start" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("older-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("older-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId: olderRequestId }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/completed", + scope: turnScope("older-turn"), + providerThreadId, + itemId: "older-message", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { + type: "agentMessage", + id: "older-message", + text: "Earlier answer.", + }, + }), + }, + { + threadId: thread.id, + sequence: 5, + type: "turn/completed", + scope: turnScope("older-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ status: "completed" }), + }, + { + threadId: thread.id, + sequence: 10, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + source: "tell", + initiator: "user", + request: { method: "turn/start", params: {} }, + requestId: firstRequestId, + senderThreadId: null, + input: [{ type: "text", text: "Do all the work.", mentions: [] }], + target: { kind: "new-turn" }, + execution, + }), + }, + { + threadId: thread.id, + sequence: 11, + type: "turn/started", + scope: turnScope("giant-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 12, + type: "turn/input/accepted", + scope: turnScope("giant-turn"), + providerThreadId, + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId: firstRequestId }), + }, + ...Array.from({ length: assistantMessageCount }, (_, index) => ({ + threadId: thread.id, + sequence: index + 13, + type: "item/completed" as const, + scope: turnScope("giant-turn"), + providerThreadId, + itemId: `message-${index + 1}`, + itemKind: "agentMessage" as const, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: `message-${index + 1}`, + text: `Assistant message ${index + 1}`, + }, + }), + })), + ]); + const maxSeq = assistantMessageCount + 12; + + const latest = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq, + page: { kind: "latest", segmentLimit: 20 }, + }); + expect(latest.rows.length).toBeLessThanOrEqual( + THREAD_TIMELINE_PAGE_ROW_LIMIT, + ); + expect(latest.rows[0]).toMatchObject({ + kind: "conversation", + role: "user", + text: "Do all the work.", + }); + const summary = latest.rows.find( + (row): row is Extract => + row.kind === "turn", + ); + expect(summary).toMatchObject({ + sourceSeqStart: 11, + status: "pending", + turnId: "giant-turn", + }); + expect(latest.timelinePage).toMatchObject({ + hasOlderRows: true, + }); + + const topLevelCursor = latest.timelinePage.olderCursor; + if (!topLevelCursor) { + throw new Error("Expected an earlier-conversation cursor"); + } + const olderTimeline = buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq, + page: { + beforeCursor: topLevelCursor, + kind: "older", + segmentLimit: 20, + }, + }); + expect(rowTexts(olderTimeline.rows)).toContain("Earlier question."); + expect(rowTexts(olderTimeline.rows)).toContain("Earlier answer."); + expect(rowTexts(olderTimeline.rows)).not.toContain("Do all the work."); + expect(() => + buildThreadTimeline(db, thread, { + includeProviderUnhandledOperations: false, + maxSeq, + page: { + beforeCursor: { + ...topLevelCursor, + anchorId: `${topLevelCursor.anchorId}-stale`, + }, + kind: "older", + segmentLimit: 20, + }, + }), + ).toThrow("cursor is no longer available"); + + if (!summary) { + throw new Error("Expected active work summary"); + } + const scopedFirstPage = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: null, + contextItemIds: ["context-b", "context-a", "context-a"], + includeProviderUnhandledOperations: false, + sourceSeqEnd: maxSeq, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }); + const scopedCursor = scopedFirstPage.timelinePage.olderCursor; + if (!scopedCursor) { + throw new Error("Expected a scoped turn-detail cursor"); + } + expect( + buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: scopedCursor, + contextItemIds: ["context-a", "context-b"], + includeProviderUnhandledOperations: false, + sourceSeqEnd: maxSeq, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }).rows.length, + ).toBeGreaterThan(0); + expect(() => + buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: scopedCursor, + contextItemIds: ["context-a", "context-c"], + includeProviderUnhandledOperations: false, + sourceSeqEnd: maxSeq, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }), + ).toThrow("cursor is no longer available"); + + const detailRows: TimelineRow[] = []; + let beforeCursor: TimelinePaginationCursor | null = null; + let firstOlderCursor: TimelinePaginationCursor | null = null; + do { + const details = buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor, + contextItemIds: summary.detailContextItemIds, + includeProviderUnhandledOperations: false, + sourceSeqEnd: summary.sourceSeqEnd, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }); + detailRows.unshift(...details.rows); + beforeCursor = details.timelinePage.olderCursor; + firstOlderCursor ??= beforeCursor; + } while (beforeCursor !== null); + + if (firstOlderCursor) { + expect(() => + buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: firstOlderCursor, + contextItemIds: summary.detailContextItemIds, + includeProviderUnhandledOperations: false, + sourceSeqEnd: summary.sourceSeqEnd - 1, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }), + ).toThrow("cursor is no longer available"); + expect(() => + buildTimelineTurnSummaryDetails(db, thread, { + beforeCursor: { + ...firstOlderCursor, + anchorId: `${firstOlderCursor.anchorId}-stale`, + }, + contextItemIds: summary.detailContextItemIds, + includeProviderUnhandledOperations: false, + sourceSeqEnd: summary.sourceSeqEnd, + sourceSeqStart: summary.sourceSeqStart, + turnId: summary.turnId, + }), + ).toThrow("cursor is no longer available"); + } + + const assistantTexts = [...detailRows, ...latest.rows].flatMap((row) => + row.kind === "conversation" && row.role === "assistant" ? [row.text] : [], + ); + expect(assistantTexts).toEqual( + Array.from( + { length: assistantMessageCount }, + (_, index) => `Assistant message ${index + 1}`, + ), + ); + }); }); diff --git a/apps/server/test/services/threads/timeline-turn-details-pagination.test.ts b/apps/server/test/services/threads/timeline-turn-details-pagination.test.ts new file mode 100644 index 0000000000..b044f25134 --- /dev/null +++ b/apps/server/test/services/threads/timeline-turn-details-pagination.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineRow } from "@bb/server-contract"; +import { paginateTimelineTurnDetails } from "../../../src/services/threads/timeline-turn-details-pagination.js"; + +function assistantRow(index: number): TimelineRow { + return { + id: `assistant-${index}`, + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: index, + sourceSeqEnd: index, + startedAt: index, + createdAt: index, + kind: "conversation", + role: "assistant", + text: `message ${index}`, + attachments: null, + turnRequest: null, + }; +} + +describe("paginateTimelineTurnDetails", () => { + it("uses only the exact raw-event continuation boundary", () => { + const rows = Array.from({ length: 125 }, (_, index) => + assistantRow(index + 1), + ); + const eventWindowOlderCursor = { + anchorId: "timeline-event-window:event-10", + anchorSeq: 10, + }; + + expect( + paginateTimelineTurnDetails(rows, { eventWindowOlderCursor }), + ).toEqual({ + hasOlderRows: true, + olderCursor: eventWindowOlderCursor, + rows, + }); + }); + + it("keeps the raw event cursor when context filtering empties a page", () => { + const eventWindowOlderCursor = { + anchorId: "timeline-event-window:event-10", + anchorSeq: 10, + }; + expect(paginateTimelineTurnDetails([], { eventWindowOlderCursor })).toEqual( + { + hasOlderRows: true, + olderCursor: eventWindowOlderCursor, + rows: [], + }, + ); + }); +}); diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 751f89178f..36521906af 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1,5 +1,6 @@ import { and, + count, desc, eq, gt, @@ -9,6 +10,8 @@ import { lt, lte, max, + min, + ne, notExists, notInArray, or, @@ -700,11 +703,466 @@ const storedEventRowFields = { type: events.type, }; +const timelineDeltaEventTypes = [ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/plan/delta", +] satisfies readonly ThreadEventType[]; + +/** + * Keep event-window queries from materializing an arbitrarily large JSON + * payload in the server. Oversized deltas retain a bounded prefix and an + * explicit marker. Oversized background-task snapshots retain the lifecycle + * fields needed by live task cards while dropping the potentially large + * workflow tree. Other oversized event kinds become a visible error row at + * the same stored identity/sequence, so pagination advances without silently + * skipping the event. + */ +function boundedStoredEventRowFields(maxDataBytes: number) { + const oversized = sql`( + length(CAST(${events.data} AS BLOB)) + + length(CAST(${events.id} AS BLOB)) + + length(CAST(COALESCE(${events.itemId}, '') AS BLOB)) + + length(CAST(COALESCE(${events.itemKind}, '') AS BLOB)) + + length(CAST(COALESCE(${events.providerThreadId}, '') AS BLOB)) + + length(CAST(${events.scopeKind} AS BLOB)) + + length(CAST(${events.threadId} AS BLOB)) + + length(CAST(COALESCE(${events.turnId}, '') AS BLOB)) + + length(CAST(${events.type} AS BLOB)) + ) > ${maxDataBytes}`; + const compactDeltaChars = Math.max( + 1, + Math.min(32_000, Math.floor(maxDataBytes / 12)), + ); + const compactItemIdMaxBytes = Math.max(1, Math.floor(maxDataBytes / 24)); + const compactableDelta = and( + inArray(events.type, timelineDeltaEventTypes), + sql`json_type(${events.data}, '$.delta') = 'text'`, + sql`length(CAST(COALESCE(${events.itemId}, json_extract(${events.data}, '$.itemId'), '') AS BLOB)) <= ${compactItemIdMaxBytes}`, + ); + const compactableBackgroundTask = and( + eq(events.itemKind, "backgroundTask"), + sql`json_type(${events.data}, '$.item') = 'object'`, + ); + const compactableToolCall = and( + eq(events.itemKind, "toolCall"), + inArray(events.type, ["item/started", "item/completed"]), + sql`json_type(${events.data}, '$.item') = 'object'`, + sql`length(CAST(COALESCE(${events.itemId}, json_extract(${events.data}, '$.item.id'), '') AS BLOB)) <= ${compactItemIdMaxBytes}`, + ); + const compactableCommandExecution = and( + eq(events.itemKind, "commandExecution"), + inArray(events.type, ["item/started", "item/completed"]), + sql`json_type(${events.data}, '$.item') = 'object'`, + sql`length(CAST(COALESCE(${events.itemId}, json_extract(${events.data}, '$.item.id'), '') AS BLOB)) <= ${compactItemIdMaxBytes}`, + ); + const compactableTurnStarted = and( + eq(events.type, "turn/started"), + sql`length(CAST(COALESCE(json_extract(${events.data}, '$.parentToolCallId'), '') AS BLOB)) <= 256`, + ); + const compactableGoalUpdate = and( + eq(events.type, "thread/goal/updated"), + sql`json_type(${events.data}, '$.status') = 'text'`, + ); + const compactableAcceptedInput = and( + eq(events.type, "turn/input/accepted"), + sql`json_type(${events.data}, '$.clientRequestId') = 'text'`, + sql`length(CAST(json_extract(${events.data}, '$.clientRequestId') AS BLOB)) <= 64`, + ); + const compactDeltaData = sql`CASE + WHEN ${events.type} = 'item/commandExecution/outputDelta' + AND json_extract(${events.data}, '$.reset') = 1 + THEN json_object( + 'itemId', COALESCE(${events.itemId}, json_extract(${events.data}, '$.itemId'), 'oversized-item'), + 'delta', substr(json_extract(${events.data}, '$.delta'), 1, ${compactDeltaChars}) || '\n…[oversized event truncated for timeline rendering]\n', + 'reset', json('true') + ) + ELSE json_object( + 'itemId', COALESCE(${events.itemId}, json_extract(${events.data}, '$.itemId'), 'oversized-item'), + 'delta', substr(json_extract(${events.data}, '$.delta'), 1, ${compactDeltaChars}) || '\n…[oversized event truncated for timeline rendering]\n' + ) + END`; + const compactBackgroundTaskData = sql`json_patch( + json_patch( + json_object( + 'item', json_object( + 'type', 'backgroundTask', + 'id', substr(COALESCE(json_extract(${events.data}, '$.item.id'), ${events.itemId}, 'oversized-task'), 1, 256), + 'taskType', substr(COALESCE(json_extract(${events.data}, '$.item.taskType'), 'unknown'), 1, 256), + 'description', substr(COALESCE(json_extract(${events.data}, '$.item.description'), 'Background task'), 1, 256) || ' …[large task details omitted]', + 'status', COALESCE(json_extract(${events.data}, '$.item.status'), 'pending'), + 'taskStatus', COALESCE(json_extract(${events.data}, '$.item.taskStatus'), 'running'), + 'skipTranscript', CASE + WHEN json_extract(${events.data}, '$.item.skipTranscript') = 1 THEN json('true') + ELSE json('false') + END + ) + ), + CASE + WHEN json_type(${events.data}, '$.item.workflowName') = 'text' + THEN json_object('item', json_object('workflowName', substr(json_extract(${events.data}, '$.item.workflowName'), 1, 256))) + ELSE '{}' + END + ), + CASE + WHEN json_type(${events.data}, '$.item.parentToolCallId') = 'text' + THEN json_object('item', json_object('parentToolCallId', substr(json_extract(${events.data}, '$.item.parentToolCallId'), 1, 256))) + ELSE '{}' + END + )`; + const compactToolCallData = sql`json_patch( + json_patch( + json_object( + 'item', json_object( + 'type', 'toolCall', + 'id', COALESCE(${events.itemId}, json_extract(${events.data}, '$.item.id'), 'oversized-tool'), + 'tool', substr(COALESCE(json_extract(${events.data}, '$.item.tool'), 'unknown'), 1, 256), + 'status', COALESCE(json_extract(${events.data}, '$.item.status'), 'failed') + ) + ), + CASE + WHEN json_type(${events.data}, '$.item.server') = 'text' + THEN json_object('item', json_object('server', substr(json_extract(${events.data}, '$.item.server'), 1, 256))) + ELSE '{}' + END + ), + CASE + WHEN json_type(${events.data}, '$.item.parentToolCallId') = 'text' + THEN json_object('item', json_object('parentToolCallId', substr(json_extract(${events.data}, '$.item.parentToolCallId'), 1, 256))) + ELSE '{}' + END + )`; + const compactCommandExecutionData = sql`json_patch( + json_object( + 'item', json_object( + 'type', 'commandExecution', + 'id', COALESCE(${events.itemId}, json_extract(${events.data}, '$.item.id'), 'oversized-command'), + 'command', substr(COALESCE(json_extract(${events.data}, '$.item.command'), 'oversized command'), 1, 512) || ' …[large command details omitted]', + 'cwd', substr(COALESCE(json_extract(${events.data}, '$.item.cwd'), '.'), 1, 256), + 'status', COALESCE(json_extract(${events.data}, '$.item.status'), 'failed'), + 'approvalStatus', json_extract(${events.data}, '$.item.approvalStatus') + ) + ), + CASE + WHEN json_type(${events.data}, '$.item.parentToolCallId') = 'text' + THEN json_object('item', json_object('parentToolCallId', substr(json_extract(${events.data}, '$.item.parentToolCallId'), 1, 256))) + ELSE '{}' + END + )`; + const compactTurnStartedData = sql`CASE + WHEN json_type(${events.data}, '$.parentToolCallId') = 'text' + THEN json_object('parentToolCallId', json_extract(${events.data}, '$.parentToolCallId')) + ELSE '{}' + END`; + const compactGoalUpdateData = sql`json_object( + 'objective', substr(COALESCE(json_extract(${events.data}, '$.objective'), ''), 1, 512) || ' …[large goal details omitted]', + 'status', json_extract(${events.data}, '$.status'), + 'tokenBudget', json_extract(${events.data}, '$.tokenBudget'), + 'tokensUsed', COALESCE(json_extract(${events.data}, '$.tokensUsed'), 0), + 'timeUsedSeconds', COALESCE(json_extract(${events.data}, '$.timeUsedSeconds'), 0) + )`; + const compactAcceptedInputData = sql`json_object( + 'clientRequestId', json_extract(${events.data}, '$.clientRequestId') + )`; + const placeholderData = sql`json_object( + 'code', 'timeline_event_payload_too_large', + 'message', 'A ' || ${events.type} || ' event (' || length(CAST(${events.data} AS BLOB)) || ' bytes) was too large to render inline. The stored event was retained.' + )`; + const canKeepType = or( + compactableDelta, + compactableBackgroundTask, + compactableToolCall, + compactableCommandExecution, + compactableTurnStarted, + compactableGoalUpdate, + compactableAcceptedInput, + ); + + return { + ...storedEventRowFields, + data: sql`CASE + WHEN NOT ${oversized} THEN ${events.data} + WHEN ${compactableDelta} THEN ${compactDeltaData} + WHEN ${compactableBackgroundTask} THEN ${compactBackgroundTaskData} + WHEN ${compactableToolCall} THEN ${compactToolCallData} + WHEN ${compactableCommandExecution} THEN ${compactCommandExecutionData} + WHEN ${compactableTurnStarted} THEN ${compactTurnStartedData} + WHEN ${compactableGoalUpdate} THEN ${compactGoalUpdateData} + WHEN ${compactableAcceptedInput} THEN ${compactAcceptedInputData} + ELSE ${placeholderData} + END`, + itemId: sql`CASE + WHEN ${oversized} AND NOT ${canKeepType} THEN NULL + ELSE ${events.itemId} + END`, + itemKind: sql`CASE + WHEN ${oversized} AND NOT ${canKeepType} THEN NULL + ELSE ${events.itemKind} + END`, + type: sql`CASE + WHEN ${oversized} AND NOT ${canKeepType} THEN 'system/error' + ELSE ${events.type} + END`, + }; +} + export type StoredEventRow = Pick< typeof events.$inferSelect, keyof typeof storedEventRowFields >; +/** + * Payload rows selected under both a row ceiling and a cumulative UTF-8 byte + * ceiling inside SQLite. `dataBytes` retains its historical name, but measures + * every variable-width text column returned in `rows`, not only `data`. + */ +export interface BoundedStoredEventRowsResult { + dataBytes: number; + hasMore: boolean; + rows: StoredEventRow[]; +} + +interface ListBoundedStoredEventRowsArgs { + condition: SQL | undefined; + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; + maxDataBytes?: number; +} + +interface RawBoundedStoredEventRow { + createdAt: number | null; + cumulativeBytes: number; + data: string | null; + dataBytes: number; + hasMore: number; + id: string | null; + isMetadata: number; + itemId: string | null; + itemKind: ThreadEventItemType | null; + providerThreadId: string | null; + scopeKind: ThreadEventScopeKind | null; + sequence: number | null; + threadId: string | null; + turnId: string | null; + type: ThreadEventType | null; +} + +/** + * Select a contiguous newest-first prefix without allowing rejected payloads + * to cross the SQLite boundary. The extra candidate used to distinguish row + * cutoff from exhaustion remains inside the CTE; JavaScript receives only the + * selected rows and one metadata sentinel. + */ +function listBoundedStoredEventRows( + db: DbConnection, + args: ListBoundedStoredEventRowsArgs, +): BoundedStoredEventRowsResult { + const limit = Math.max(0, Math.floor(args.limit)); + const maxBytes = Math.max(0, Math.floor(args.maxBytes)); + if (limit === 0 || maxBytes === 0) { + return { dataBytes: 0, hasMore: false, rows: [] }; + } + + const excludedRowIds = [...new Set(args.excludedRowIds ?? [])]; + const condition = and( + args.condition, + excludedRowIds.length > 0 + ? notInArray(events.id, excludedRowIds) + : undefined, + ); + const fields = boundedStoredEventRowFields( + Math.min(maxBytes, args.maxDataBytes ?? maxBytes), + ); + // If compacted data still cannot fit because auxiliary identity metadata is + // oversized, replace the whole renderable projection instead of truncating + // an ownership id into a plausible-but-wrong value. Cursor identity remains + // the stored id/thread/sequence/createdAt tuple. + const rawRows = db.all(sql` + WITH + compacted_raw AS MATERIALIZED ( + SELECT + ${fields.createdAt} AS createdAt, + ${fields.data} AS data, + ${fields.id} AS id, + ${fields.itemId} AS itemId, + ${fields.itemKind} AS itemKind, + ${fields.providerThreadId} AS providerThreadId, + ${fields.scopeKind} AS scopeKind, + ${fields.sequence} AS sequence, + ${fields.threadId} AS threadId, + ${fields.turnId} AS turnId, + ${fields.type} AS type + FROM ${events} + WHERE ${condition ?? sql`1`} + ORDER BY ${events.sequence} DESC + LIMIT ${limit + 1} + ), + compacted_measured AS MATERIALIZED ( + SELECT + compacted_raw.*, + ( + length(CAST(data AS BLOB)) + + length(CAST(id AS BLOB)) + + length(CAST(COALESCE(itemId, '') AS BLOB)) + + length(CAST(COALESCE(itemKind, '') AS BLOB)) + + length(CAST(COALESCE(providerThreadId, '') AS BLOB)) + + length(CAST(scopeKind AS BLOB)) + + length(CAST(threadId AS BLOB)) + + length(CAST(COALESCE(turnId, '') AS BLOB)) + + length(CAST(type AS BLOB)) + ) AS compactedRowBytes + FROM compacted_raw + ), + compacted AS MATERIALIZED ( + SELECT + createdAt, + CASE + WHEN compactedRowBytes > ${maxBytes} + THEN json_object( + 'code', 'timeline_event_identity_too_large', + 'message', 'An event contained identity metadata too large to render inline. The stored event was retained.' + ) + ELSE data + END AS data, + id, + CASE WHEN compactedRowBytes > ${maxBytes} THEN NULL ELSE itemId END AS itemId, + CASE WHEN compactedRowBytes > ${maxBytes} THEN NULL ELSE itemKind END AS itemKind, + CASE WHEN compactedRowBytes > ${maxBytes} THEN NULL ELSE providerThreadId END AS providerThreadId, + CASE WHEN compactedRowBytes > ${maxBytes} THEN 'thread' ELSE scopeKind END AS scopeKind, + sequence, + threadId, + CASE WHEN compactedRowBytes > ${maxBytes} THEN NULL ELSE turnId END AS turnId, + CASE WHEN compactedRowBytes > ${maxBytes} THEN 'system/error' ELSE type END AS type + FROM compacted_measured + ), + measured AS MATERIALIZED ( + SELECT + compacted.*, + ( + length(CAST(data AS BLOB)) + + length(CAST(id AS BLOB)) + + length(CAST(COALESCE(itemId, '') AS BLOB)) + + length(CAST(COALESCE(itemKind, '') AS BLOB)) + + length(CAST(COALESCE(providerThreadId, '') AS BLOB)) + + length(CAST(scopeKind AS BLOB)) + + length(CAST(threadId AS BLOB)) + + length(CAST(COALESCE(turnId, '') AS BLOB)) + + length(CAST(type AS BLOB)) + ) AS rowBytes, + SUM( + length(CAST(data AS BLOB)) + + length(CAST(id AS BLOB)) + + length(CAST(COALESCE(itemId, '') AS BLOB)) + + length(CAST(COALESCE(itemKind, '') AS BLOB)) + + length(CAST(COALESCE(providerThreadId, '') AS BLOB)) + + length(CAST(scopeKind AS BLOB)) + + length(CAST(threadId AS BLOB)) + + length(CAST(COALESCE(turnId, '') AS BLOB)) + + length(CAST(type AS BLOB)) + ) OVER ( + ORDER BY sequence DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS cumulativeBytes, + ROW_NUMBER() OVER (ORDER BY sequence DESC) AS selectionRank + FROM compacted + ), + selected AS MATERIALIZED ( + SELECT * + FROM measured + WHERE selectionRank <= ${limit} + AND cumulativeBytes <= ${maxBytes} + ), + metadata AS ( + SELECT + COALESCE(SUM(rowBytes), 0) AS dataBytes, + CASE + WHEN (SELECT COUNT(*) FROM measured) > COUNT(*) THEN 1 + ELSE 0 + END AS hasMore + FROM selected + ) + SELECT + createdAt, + cumulativeBytes, + data, + 0 AS dataBytes, + 0 AS hasMore, + id, + 0 AS isMetadata, + itemId, + itemKind, + providerThreadId, + scopeKind, + sequence, + threadId, + turnId, + type + FROM selected + UNION ALL + SELECT + NULL AS createdAt, + 0 AS cumulativeBytes, + NULL AS data, + dataBytes, + hasMore, + NULL AS id, + 1 AS isMetadata, + NULL AS itemId, + NULL AS itemKind, + NULL AS providerThreadId, + NULL AS scopeKind, + NULL AS sequence, + NULL AS threadId, + NULL AS turnId, + NULL AS type + FROM metadata + ORDER BY isMetadata, sequence DESC + `); + + const metadata = rawRows.at(-1); + if (!metadata || metadata.isMetadata !== 1) { + throw new Error("Bounded stored event query did not return metadata"); + } + const rows = rawRows.slice(0, -1).map((row): StoredEventRow => { + if ( + row.createdAt === null || + row.data === null || + row.id === null || + row.scopeKind === null || + row.sequence === null || + row.threadId === null || + row.type === null + ) { + throw new Error("Bounded stored event query returned an invalid row"); + } + return { + createdAt: row.createdAt, + data: row.data, + id: row.id, + itemId: row.itemId, + itemKind: row.itemKind, + providerThreadId: row.providerThreadId, + scopeKind: row.scopeKind, + sequence: row.sequence, + threadId: row.threadId, + turnId: row.turnId, + type: row.type, + }; + }); + if (rows.length > limit || metadata.dataBytes > maxBytes) { + throw new Error("Bounded stored event query exceeded its requested budget"); + } + return { + dataBytes: metadata.dataBytes, + hasMore: metadata.hasMore === 1, + rows, + }; +} + export interface ListStoredEventRowsArgs { afterSequence?: number; limit?: number; @@ -723,12 +1181,140 @@ export interface ListStoredEventRowsInRangeArgs { threadId: string; } +export interface GetStoredEventIdentityAtSequenceArgs { + sequence: number; + threadId: string; +} + +export interface StoredEventIdentity { + id: string; + sequence: number; +} + +export interface HasStoredTurnEventInRangeArgs { + seqEnd: number; + seqStart: number; + threadId: string; + turnId: string; +} + export interface ListStoredEventRowsByParentToolCallIdsArgs { + excludedRowIds?: readonly string[]; + excludedTypes?: readonly ThreadEventType[]; + /** Return at most this many newest matching rows. */ + limit: number; + maxBytes: number; + parentToolCallIds: readonly string[]; + sequenceEnd?: number; + sequenceStart?: number; + threadId: string; +} + +export interface ListStoredParentedTurnRangesArgs { + excludedTypes?: readonly ThreadEventType[]; + parentToolCallIds: readonly string[]; + sequenceEnd?: number; + sequenceStart?: number; + threadId: string; +} + +export interface ListStoredDelegationChildTurnRangesArgs { + /** Return only child turns whose first direct event is before this sequence. */ + beforeSequence?: number; + directTurnSourceSeqEnd: number; + directTurnSourceSeqStart: number; + excludedTypes?: readonly ThreadEventType[]; + limit: number; + ownerTurnId: string; + parentToolCallId: string; + sequenceEnd: number; + sequenceStart: number; + threadId: string; +} + +export interface ListStoredNonemptyDelegationChildTurnBucketIndexesArgs { + buckets: readonly { + directTurnSourceSeqEnd: number; + directTurnSourceSeqStart: number; + }[]; excludedTypes?: readonly ThreadEventType[]; + ownerTurnId: string; + parentToolCallId: string; + sequenceEnd: number; + sequenceStart: number; + threadId: string; +} + +export interface ListStoredDelegationDescendantRangesArgs { + roots: readonly { + ownerTurnId: string; + parentToolCallId: string; + }[]; + sequenceEnd: number; + sequenceStart: number; + threadId: string; +} + +export interface ListStoredTurnDescendantRangesArgs { + roots: readonly { + sourceSeqEnd: number; + sourceSeqStart: number; + turnId: string; + }[]; + sequenceEnd: number; + threadId: string; +} + +export interface ListStoredDelegatedTurnDescendantRangesArgs { + roots: readonly { + parentToolCallId: string; + turnId: string; + }[]; + sequenceEnd: number; + sequenceStart: number; + threadId: string; +} + +export interface ListStoredParentedToolCallsArgs { parentToolCallIds: readonly string[]; + sequenceEnd?: number; + sequenceStart?: number; threadId: string; } +export interface StoredParentedToolCall { + itemId: string; + turnId: string; +} + +export interface StoredParentedTurnRange { + completedAt: number | null; + createdAt: number; + eventCount: number; + parentToolCallId: string; + sourceSeqEnd: number; + sourceSeqStart: number; + startedAt: number; + turnId: string; +} + +export interface StoredDelegationDescendantRange { + eventCount: number; + parentToolCallId: string; + sourceSeqEnd: number; + sourceSeqStart: number; +} + +export interface StoredTurnDescendantRange { + descendantSourceSeqEnd: number; + sourceSeqEnd: number; + sourceSeqStart: number; + turnId: string; +} + +export interface StoredDelegatedTurnDescendantRange + extends StoredParentedTurnRange {} + export interface ListStoredEventRowsByThreadIdsAndTypesArgs { threadIds: readonly string[]; types: readonly ThreadEventType[]; @@ -738,6 +1324,13 @@ export interface ListLatestGoalEventRowsByThreadIdsArgs { threadIds: readonly string[]; } +export interface ListLatestGoalEventRowsForThreadArgs { + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; + threadId: string; +} + export interface ListOpenTurnInputAcceptedRowsByThreadIdsArgs { threadIds: readonly string[]; } @@ -752,13 +1345,42 @@ export interface ListStoredClientTurnRequestRowsByKeysArgs { } export interface ListStoredToolCallRowsByItemIdsArgs { + excludedRowIds?: readonly string[]; itemIds: readonly string[]; + limit: number; + maxBytes: number; + sequenceEnd?: number; threadId: string; } +export interface ListStoredItemStartedRowsByItemIdsArgs { + excludedRowIds?: readonly string[]; + itemIds: readonly string[]; + limit: number; + maxBytes: number; + sequenceCutoff?: number; + threadId: string; +} + +export interface ListStoredItemLifecycleOwnerSequencesArgs { + itemIds: readonly string[]; + seqEnd: number; + seqStart: number; + threadId: string; +} + +export interface StoredItemLifecycleOwnerSequence { + itemId: string; + sequence: number; +} + export interface ListStoredTurnInputAcceptedRowsByClientRequestIdsArgs { afterSequence: number; + beforeOrAtSequence?: number; clientRequestIds: readonly ClientTurnRequestId[]; + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; threadId: string; } @@ -788,6 +1410,9 @@ export interface GetLatestThreadInterruptedReasonArgs { } export interface ListStoredTurnStartedRowsByTurnIdsUpToSequenceArgs { + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; sequenceCutoff: number; threadId: string; turnIds: readonly string[]; @@ -812,6 +1437,10 @@ export interface ListRecentStoredEventRowsArgs { threadId: string; } +export interface ListStoredConversationOutlineEventRowsArgs { + threadId: string; +} + export interface ListStoredTimelineWindowEventRowsArgs { beforeSequence?: number; excludedTypes?: readonly ThreadEventType[]; @@ -819,7 +1448,17 @@ export interface ListStoredTimelineWindowEventRowsArgs { threadId: string; } +export interface ListStoredTimelineWindowEventRowsDescendingArgs + extends ListStoredTimelineWindowEventRowsArgs { + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; +} + export interface ListContextWindowUsageRowsArgs { + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; threadId: string; } @@ -969,6 +1608,31 @@ export function listLatestGoalEventRowsByThreadIds( .all(); } +export function listLatestGoalEventRowsForThread( + db: DbConnection, + args: ListLatestGoalEventRowsForThreadArgs, +): BoundedStoredEventRowsResult { + const goalTypes = [ + "thread/goal/updated", + "thread/goal/cleared", + ] satisfies ThreadEventType[]; + return listBoundedStoredEventRows(db, { + condition: and( + eq(events.threadId, args.threadId), + inArray(events.type, goalTypes), + sql`${events.sequence} = ( + SELECT MAX(latest.sequence) + FROM events latest + WHERE latest.thread_id = ${events.threadId} + AND latest.type IN (${goalTypes[0]}, ${goalTypes[1]}) + )`, + ), + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); +} + export function listOpenTurnInputAcceptedRowsByThreadIds( db: DbQueryConnection, args: ListOpenTurnInputAcceptedRowsByThreadIdsArgs, @@ -1023,71 +1687,776 @@ export function listStoredClientTurnRequestRowsByKeys( args.keys.map((key) => [`${key.threadId}\0${key.requestId}`, key]), ).values(), ]; - if (uniqueKeys.length === 0) { + if (uniqueKeys.length === 0) { + return []; + } + + const requestType = "client/turn/requested" satisfies ThreadEventType; + const keyConditions = uniqueKeys.map((key) => + and( + eq(events.threadId, key.threadId), + sql`json_extract(${events.data}, '$.requestId') = ${key.requestId}`, + ), + ); + + return db + .select(storedEventRowFields) + .from(events) + .where(and(eq(events.type, requestType), or(...keyConditions))) + .orderBy(events.threadId, events.sequence) + .all(); +} + +export function findStoredEventRow( + db: DbConnection, + args: FindStoredEventRowArgs, +): StoredEventRow | null { + return ( + db + .select(storedEventRowFields) + .from(events) + .where( + args.afterSequence !== undefined + ? and( + eq(events.threadId, args.threadId), + eq(events.type, args.type), + gt(events.sequence, args.afterSequence), + ) + : and(eq(events.threadId, args.threadId), eq(events.type, args.type)), + ) + .orderBy(events.sequence) + .limit(1) + .get() ?? null + ); +} + +export function listStoredEventRowsInRange( + db: DbConnection, + args: ListStoredEventRowsInRangeArgs, +): StoredEventRow[] { + return db + .select(storedEventRowFields) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + gte(events.sequence, args.seqStart), + lte(events.sequence, args.seqEnd), + ), + ) + .orderBy(events.sequence) + .all(); +} + +export function getStoredEventIdentityAtSequence( + db: DbConnection, + args: GetStoredEventIdentityAtSequenceArgs, +): StoredEventIdentity | null { + return ( + db + .select({ id: events.id, sequence: events.sequence }) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.sequence, args.sequence), + ), + ) + .limit(1) + .get() ?? null + ); +} + +export function hasStoredTurnEventInRange( + db: DbConnection, + args: HasStoredTurnEventInRangeArgs, +): boolean { + return ( + db + .select({ id: events.id }) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.turnId, args.turnId), + gte(events.sequence, args.seqStart), + lte(events.sequence, args.seqEnd), + ), + ) + .limit(1) + .get() !== undefined + ); +} + +export function listStoredEventRowsByParentToolCallIds( + db: DbConnection, + args: ListStoredEventRowsByParentToolCallIdsArgs, +): BoundedStoredEventRowsResult { + const parentToolCallIds = [...new Set(args.parentToolCallIds)].filter( + (parentToolCallId) => parentToolCallId.length > 0, + ); + if (parentToolCallIds.length === 0) { + return { dataBytes: 0, hasMore: false, rows: [] }; + } + + const eventParentToolCallId = sql`json_extract(${events.data}, '$.parentToolCallId')`; + const itemParentToolCallId = sql`json_extract(${events.data}, '$.item.parentToolCallId')`; + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + or( + inArray(eventParentToolCallId, parentToolCallIds), + inArray(itemParentToolCallId, parentToolCallIds), + )!, + ]; + if (args.excludedTypes && args.excludedTypes.length > 0) { + conditions.push(notInArray(events.type, [...args.excludedTypes])); + } + if (args.sequenceStart !== undefined) { + conditions.push(gte(events.sequence, args.sequenceStart)); + } + if (args.sequenceEnd !== undefined) { + conditions.push(lte(events.sequence, args.sequenceEnd)); + } + + const result = listBoundedStoredEventRows(db, { + condition: and(...conditions), + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); + return { ...result, rows: result.rows.reverse() }; +} + +export function listStoredParentedTurnRanges( + db: DbConnection, + args: ListStoredParentedTurnRangesArgs, +): StoredParentedTurnRange[] { + const parentToolCallIds = [...new Set(args.parentToolCallIds)].filter( + (parentToolCallId) => parentToolCallId.length > 0, + ); + if (parentToolCallIds.length === 0) { + return []; + } + + const parentToolCallId = sql`COALESCE( + json_extract(${events.data}, '$.item.parentToolCallId'), + json_extract(${events.data}, '$.parentToolCallId') + )`; + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + isNotNull(events.turnId), + inArray(parentToolCallId, parentToolCallIds), + ]; + if (args.excludedTypes && args.excludedTypes.length > 0) { + conditions.push(notInArray(events.type, [...args.excludedTypes])); + } + if (args.sequenceStart !== undefined) { + conditions.push(gte(events.sequence, args.sequenceStart)); + } + if (args.sequenceEnd !== undefined) { + conditions.push(lte(events.sequence, args.sequenceEnd)); + } + + return db + .select({ + completedAt: sql`MAX(CASE WHEN ${events.type} = 'turn/completed' THEN ${events.createdAt} END)`, + createdAt: max(events.createdAt), + eventCount: count(), + parentToolCallId, + sourceSeqEnd: max(events.sequence), + sourceSeqStart: min(events.sequence), + startedAt: min(events.createdAt), + turnId: events.turnId, + }) + .from(events) + .where(and(...conditions)) + .groupBy(parentToolCallId, events.turnId) + .all() + .flatMap((row) => + row.turnId === null || + row.sourceSeqStart === null || + row.sourceSeqEnd === null || + row.startedAt === null || + row.createdAt === null + ? [] + : [ + { + ...row, + turnId: row.turnId, + sourceSeqStart: row.sourceSeqStart, + sourceSeqEnd: row.sourceSeqEnd, + startedAt: row.startedAt, + createdAt: row.createdAt, + }, + ], + ); +} + +/** + * Page direct child-turn identities for one delegation. The grouped result is + * bounded before it leaves SQLite; descendant work is deliberately excluded + * and becomes reachable through the child's own delegation boundaries. + */ +export function listStoredDelegationChildTurnRanges( + db: DbConnection, + args: ListStoredDelegationChildTurnRangesArgs, +): StoredParentedTurnRange[] { + if ( + args.parentToolCallId.length === 0 || + args.limit <= 0 || + args.sequenceStart > args.sequenceEnd || + args.directTurnSourceSeqStart > args.directTurnSourceSeqEnd + ) { + return []; + } + + const parentToolCallId = sql`COALESCE( + json_extract(${events.data}, '$.item.parentToolCallId'), + json_extract(${events.data}, '$.parentToolCallId') + )`; + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + isNotNull(events.turnId), + ne(events.turnId, args.ownerTurnId), + eq(parentToolCallId, args.parentToolCallId), + gte(events.sequence, args.sequenceStart), + lte(events.sequence, args.sequenceEnd), + ]; + if (args.excludedTypes && args.excludedTypes.length > 0) { + conditions.push(notInArray(events.type, [...args.excludedTypes])); + } + + const completeSnapshotRanges = db + .select({ + completedAt: + sql`MAX(CASE WHEN ${events.type} = 'turn/completed' THEN ${events.createdAt} END)`.as( + "completed_at", + ), + createdAt: max(events.createdAt).as("created_at"), + eventCount: count().as("event_count"), + parentToolCallId: parentToolCallId.as("parent_tool_call_id"), + sourceSeqEnd: max(events.sequence).as("source_seq_end"), + sourceSeqStart: min(events.sequence).as("source_seq_start"), + startedAt: min(events.createdAt).as("started_at"), + turnId: events.turnId, + }) + .from(events) + .where(and(...conditions)) + .groupBy(parentToolCallId, events.turnId) + .as("complete_delegation_child_turn_ranges"); + const intervalConditions: SQL[] = [ + gte( + completeSnapshotRanges.sourceSeqStart, + args.directTurnSourceSeqStart, + ), + lte(completeSnapshotRanges.sourceSeqStart, args.directTurnSourceSeqEnd), + ]; + if (args.beforeSequence !== undefined) { + intervalConditions.push( + lt(completeSnapshotRanges.sourceSeqStart, args.beforeSequence), + ); + } + const rows = db + .select() + .from(completeSnapshotRanges) + .where(and(...intervalConditions)) + .orderBy( + desc(completeSnapshotRanges.sourceSeqStart), + desc(completeSnapshotRanges.turnId), + ) + .limit(args.limit) + .all(); + + return rows.flatMap((row) => + row.turnId === null || + row.sourceSeqStart === null || + row.sourceSeqEnd === null || + row.startedAt === null || + row.createdAt === null + ? [] + : [ + { + ...row, + turnId: row.turnId, + sourceSeqStart: row.sourceSeqStart, + sourceSeqEnd: row.sourceSeqEnd, + startedAt: row.startedAt, + createdAt: row.createdAt, + }, + ], + ); +} + +/** + * Return only candidate bucket indexes that contain a direct delegated turn. + * Direct turns are grouped once across the complete immutable snapshot before + * their MIN(sequence) anchors are joined to the supplied buckets. The result + * is therefore bounded by bucket count and never exposes child identities. + */ +export function listStoredNonemptyDelegationChildTurnBucketIndexes( + db: DbConnection, + args: ListStoredNonemptyDelegationChildTurnBucketIndexesArgs, +): number[] { + const buckets = args.buckets.flatMap((bucket, bucketIndex) => + bucket.directTurnSourceSeqStart <= bucket.directTurnSourceSeqEnd + ? [{ ...bucket, bucketIndex }] + : [], + ); + if ( + buckets.length === 0 || + args.parentToolCallId.length === 0 || + args.sequenceStart > args.sequenceEnd + ) { + return []; + } + const excludedTypes = JSON.stringify(args.excludedTypes ?? []); + const rows = db.all<{ bucketIndex: number }>(sql` + WITH + buckets( + bucket_index, + direct_turn_source_seq_start, + direct_turn_source_seq_end + ) AS ( + SELECT + CAST(json_extract(value, '$.bucketIndex') AS INTEGER), + CAST(json_extract(value, '$.directTurnSourceSeqStart') AS INTEGER), + CAST(json_extract(value, '$.directTurnSourceSeqEnd') AS INTEGER) + FROM json_each(${JSON.stringify(buckets)}) + ), + complete_child_turns(source_seq_start) AS MATERIALIZED ( + SELECT MIN(child.sequence) + FROM events AS child + WHERE child.thread_id = ${args.threadId} + AND child.turn_id IS NOT NULL + AND child.turn_id <> ${args.ownerTurnId} + AND child.sequence >= ${args.sequenceStart} + AND child.sequence <= ${args.sequenceEnd} + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = ${args.parentToolCallId} + AND NOT EXISTS ( + SELECT 1 + FROM json_each(${excludedTypes}) AS excluded + WHERE excluded.value = child.type + ) + GROUP BY child.turn_id + ) + SELECT bucket.bucket_index AS bucketIndex + FROM buckets AS bucket + JOIN complete_child_turns AS child + ON child.source_seq_start >= bucket.direct_turn_source_seq_start + AND child.source_seq_start <= bucket.direct_turn_source_seq_end + GROUP BY bucket.bucket_index + ORDER BY bucket.bucket_index + LIMIT ${buckets.length} + `); + return rows.map((row) => row.bucketIndex); +} + +/** + * Resolve the complete descendant extent for bounded visible delegation + * roots. SQLite performs the graph walk and returns one scalar row per input + * root, so neither event payloads nor descendant identities enter JS memory. + * UNION (rather than UNION ALL) makes malformed cycles terminate. + */ +export function listStoredDelegationDescendantRanges( + db: DbConnection, + args: ListStoredDelegationDescendantRangesArgs, +): StoredDelegationDescendantRange[] { + const roots = [ + ...new Map( + args.roots + .filter( + (root) => + root.ownerTurnId.length > 0 && root.parentToolCallId.length > 0, + ) + .map((root) => [ + `${root.ownerTurnId}\0${root.parentToolCallId}`, + root, + ]), + ).values(), + ]; + if (roots.length === 0) { + return []; + } + + const rows = db.all<{ + eventCount: number; + parentToolCallId: string; + sourceSeqEnd: number; + sourceSeqStart: number; + }>(sql` + WITH RECURSIVE + roots(owner_turn_id, parent_tool_call_id) AS ( + SELECT + CAST(json_extract(value, '$.ownerTurnId') AS TEXT), + CAST(json_extract(value, '$.parentToolCallId') AS TEXT) + FROM json_each(${JSON.stringify(roots)}) + ), + reachable_parent_ids( + root_parent_tool_call_id, + owner_turn_id, + parent_tool_call_id + ) AS ( + SELECT parent_tool_call_id, owner_turn_id, parent_tool_call_id + FROM roots + UNION + SELECT + reachable.root_parent_tool_call_id, + reachable.owner_turn_id, + child.item_id + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.item_kind = 'toolCall' + AND child.item_id IS NOT NULL + AND child.turn_id <> reachable.owner_turn_id + AND child.sequence <= ${args.sequenceEnd} + AND child.item_id <> reachable.root_parent_tool_call_id + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + ), + reachable_turn_ids(root_parent_tool_call_id, turn_id) AS ( + SELECT DISTINCT + reachable.root_parent_tool_call_id, + child.turn_id + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence >= ${args.sequenceStart} + AND child.sequence <= ${args.sequenceEnd} + AND child.turn_id IS NOT NULL + AND child.turn_id <> reachable.owner_turn_id + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + ), + descendant_events(root_parent_tool_call_id, id, sequence) AS ( + SELECT + reachable.root_parent_tool_call_id, + child.id, + child.sequence + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence >= ${args.sequenceStart} + AND child.sequence <= ${args.sequenceEnd} + AND child.turn_id <> reachable.owner_turn_id + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + UNION + SELECT + reachable_turn.root_parent_tool_call_id, + lifecycle.id, + lifecycle.sequence + FROM reachable_turn_ids AS reachable_turn + JOIN events AS lifecycle + ON lifecycle.thread_id = ${args.threadId} + AND lifecycle.turn_id = reachable_turn.turn_id + AND lifecycle.type IN ('turn/started', 'turn/completed') + AND lifecycle.sequence >= ${args.sequenceStart} + AND lifecycle.sequence <= ${args.sequenceEnd} + ) + SELECT + descendant.root_parent_tool_call_id AS parentToolCallId, + COUNT(descendant.id) AS eventCount, + MAX(descendant.sequence) AS sourceSeqEnd, + MIN(descendant.sequence) AS sourceSeqStart + FROM descendant_events AS descendant + GROUP BY descendant.root_parent_tool_call_id + `); + return rows; +} + +/** + * Resolve the maximum descendant extent of each visible turn range without + * returning one row per delegation or child turn. + */ +export function listStoredTurnDescendantRanges( + db: DbConnection, + args: ListStoredTurnDescendantRangesArgs, +): StoredTurnDescendantRange[] { + const roots = [ + ...new Map( + args.roots + .filter( + (root) => + root.turnId.length > 0 && root.sourceSeqStart <= root.sourceSeqEnd, + ) + .map((root) => [ + `${root.turnId}\0${root.sourceSeqStart}\0${root.sourceSeqEnd}`, + root, + ]), + ).values(), + ]; + if (roots.length === 0) { return []; } - const requestType = "client/turn/requested" satisfies ThreadEventType; - const keyConditions = uniqueKeys.map((key) => - and( - eq(events.threadId, key.threadId), - sql`json_extract(${events.data}, '$.requestId') = ${key.requestId}`, - ), - ); - - return db - .select(storedEventRowFields) - .from(events) - .where(and(eq(events.type, requestType), or(...keyConditions))) - .orderBy(events.threadId, events.sequence) - .all(); -} - -export function findStoredEventRow( - db: DbConnection, - args: FindStoredEventRowArgs, -): StoredEventRow | null { - return ( - db - .select(storedEventRowFields) - .from(events) - .where( - args.afterSequence !== undefined - ? and( - eq(events.threadId, args.threadId), - eq(events.type, args.type), - gt(events.sequence, args.afterSequence), - ) - : and(eq(events.threadId, args.threadId), eq(events.type, args.type)), + return db.all(sql` + WITH RECURSIVE + roots(turn_id, source_seq_start, source_seq_end) AS ( + SELECT + CAST(json_extract(value, '$.turnId') AS TEXT), + CAST(json_extract(value, '$.sourceSeqStart') AS INTEGER), + CAST(json_extract(value, '$.sourceSeqEnd') AS INTEGER) + FROM json_each(${JSON.stringify(roots)}) + ), + reachable_parent_ids( + root_turn_id, + root_source_seq_start, + root_source_seq_end, + parent_tool_call_id + ) AS ( + SELECT + roots.turn_id, + roots.source_seq_start, + roots.source_seq_end, + direct.item_id + FROM roots + JOIN events AS direct + ON direct.thread_id = ${args.threadId} + AND direct.turn_id = roots.turn_id + AND direct.sequence >= roots.source_seq_start + AND direct.sequence <= roots.source_seq_end + AND direct.item_kind = 'toolCall' + AND direct.item_id IS NOT NULL + UNION + SELECT + reachable.root_turn_id, + reachable.root_source_seq_start, + reachable.root_source_seq_end, + child.item_id + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence <= ${args.sequenceEnd} + AND child.item_kind = 'toolCall' + AND child.item_id IS NOT NULL + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + ), + reachable_turn_ids( + root_turn_id, + root_source_seq_start, + root_source_seq_end, + turn_id + ) AS ( + SELECT DISTINCT + reachable.root_turn_id, + reachable.root_source_seq_start, + reachable.root_source_seq_end, + child.turn_id + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence >= reachable.root_source_seq_start + AND child.sequence <= ${args.sequenceEnd} + AND child.turn_id IS NOT NULL + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + ), + descendant_events( + root_turn_id, + root_source_seq_start, + root_source_seq_end, + id, + sequence + ) AS ( + SELECT + reachable.root_turn_id, + reachable.root_source_seq_start, + reachable.root_source_seq_end, + child.id, + child.sequence + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence >= reachable.root_source_seq_start + AND child.sequence <= ${args.sequenceEnd} + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.parent_tool_call_id + UNION + SELECT + reachable_turn.root_turn_id, + reachable_turn.root_source_seq_start, + reachable_turn.root_source_seq_end, + lifecycle.id, + lifecycle.sequence + FROM reachable_turn_ids AS reachable_turn + JOIN events AS lifecycle + ON lifecycle.thread_id = ${args.threadId} + AND lifecycle.turn_id = reachable_turn.turn_id + AND lifecycle.type IN ('turn/started', 'turn/completed') + AND lifecycle.sequence >= reachable_turn.root_source_seq_start + AND lifecycle.sequence <= ${args.sequenceEnd} ) - .orderBy(events.sequence) - .limit(1) - .get() ?? null - ); + SELECT + descendant.root_turn_id AS turnId, + descendant.root_source_seq_start AS sourceSeqStart, + descendant.root_source_seq_end AS sourceSeqEnd, + MAX(descendant.sequence) AS descendantSourceSeqEnd + FROM descendant_events AS descendant + GROUP BY + descendant.root_turn_id, + descendant.root_source_seq_start, + descendant.root_source_seq_end + `); } -export function listStoredEventRowsInRange( +/** + * Resolve direct child-turn metadata plus the maximum extent of each selected + * turn's nested delegation graph. The number of returned rows is exactly + * bounded by the supplied root batch. + */ +export function listStoredDelegatedTurnDescendantRanges( db: DbConnection, - args: ListStoredEventRowsInRangeArgs, -): StoredEventRow[] { - return db - .select(storedEventRowFields) - .from(events) - .where( - and( - eq(events.threadId, args.threadId), - gte(events.sequence, args.seqStart), - lte(events.sequence, args.seqEnd), + args: ListStoredDelegatedTurnDescendantRangesArgs, +): StoredDelegatedTurnDescendantRange[] { + const roots = [ + ...new Map( + args.roots + .filter( + (root) => + root.parentToolCallId.length > 0 && root.turnId.length > 0, + ) + .map((root) => [ + `${root.parentToolCallId}\0${root.turnId}`, + root, + ]), + ).values(), + ]; + if (roots.length === 0) { + return []; + } + + const rows = db.all<{ + completedAt: number | null; + createdAt: number; + eventCount: number; + parentToolCallId: string; + sourceSeqEnd: number; + sourceSeqStart: number; + startedAt: number; + turnId: string; + }>(sql` + WITH RECURSIVE + roots(parent_tool_call_id, turn_id) AS ( + SELECT + CAST(json_extract(value, '$.parentToolCallId') AS TEXT), + CAST(json_extract(value, '$.turnId') AS TEXT) + FROM json_each(${JSON.stringify(roots)}) ), - ) - .orderBy(events.sequence) - .all(); + direct_events_for_ancestry AS ( + SELECT + roots.parent_tool_call_id, + roots.turn_id, + child.id, + child.item_id, + child.item_kind, + child.type, + child.sequence, + child.created_at + FROM roots + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.turn_id = roots.turn_id + AND child.sequence <= ${args.sequenceEnd} + AND ( + child.type IN ('turn/started', 'turn/completed') + OR COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = roots.parent_tool_call_id + ) + ), + direct_events AS ( + SELECT * + FROM direct_events_for_ancestry + WHERE sequence >= ${args.sequenceStart} + ), + reachable_parent_ids(parent_tool_call_id, turn_id, nested_parent_tool_call_id) AS ( + SELECT parent_tool_call_id, turn_id, item_id + FROM direct_events_for_ancestry + WHERE item_kind = 'toolCall' + AND item_id IS NOT NULL + AND item_id <> parent_tool_call_id + UNION + SELECT + reachable.parent_tool_call_id, + reachable.turn_id, + child.item_id + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.item_kind = 'toolCall' + AND child.item_id IS NOT NULL + AND child.sequence <= ${args.sequenceEnd} + AND child.item_id <> reachable.parent_tool_call_id + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.nested_parent_tool_call_id + ), + descendant_extents AS ( + SELECT + reachable.parent_tool_call_id, + reachable.turn_id, + COUNT(child.id) AS descendant_event_count, + MAX(child.sequence) AS descendant_source_seq_end + FROM reachable_parent_ids AS reachable + JOIN events AS child + ON child.thread_id = ${args.threadId} + AND child.sequence >= ${args.sequenceStart} + AND child.sequence <= ${args.sequenceEnd} + AND COALESCE( + json_extract(child.data, '$.item.parentToolCallId'), + json_extract(child.data, '$.parentToolCallId') + ) = reachable.nested_parent_tool_call_id + GROUP BY reachable.parent_tool_call_id, reachable.turn_id + ) + SELECT + direct.parent_tool_call_id AS parentToolCallId, + direct.turn_id AS turnId, + MIN(direct.sequence) AS sourceSeqStart, + MAX( + MAX(direct.sequence), + COALESCE(extent.descendant_source_seq_end, MAX(direct.sequence)) + ) AS sourceSeqEnd, + COUNT(direct.id) + COALESCE(extent.descendant_event_count, 0) AS eventCount, + MIN(direct.created_at) AS startedAt, + MAX(direct.created_at) AS createdAt, + MAX(CASE WHEN direct.type = 'turn/completed' THEN direct.created_at END) AS completedAt + FROM direct_events AS direct + LEFT JOIN descendant_extents AS extent + ON extent.parent_tool_call_id = direct.parent_tool_call_id + AND extent.turn_id = direct.turn_id + GROUP BY direct.parent_tool_call_id, direct.turn_id + `); + return rows; } -export function listStoredEventRowsByParentToolCallIds( +/** + * Discover nested delegation identities without loading their event payloads. + * Recursive timeline range discovery must not depend on whether a tool-call + * row fits inside the separate projection enrichment budget. + */ +export function listStoredParentedToolCalls( db: DbConnection, - args: ListStoredEventRowsByParentToolCallIdsArgs, -): StoredEventRow[] { + args: ListStoredParentedToolCallsArgs, +): StoredParentedToolCall[] { const parentToolCallIds = [...new Set(args.parentToolCallIds)].filter( (parentToolCallId) => parentToolCallId.length > 0, ); @@ -1095,51 +2464,124 @@ export function listStoredEventRowsByParentToolCallIds( return []; } - const eventParentToolCallId = sql`json_extract(${events.data}, '$.parentToolCallId')`; - const itemParentToolCallId = sql`json_extract(${events.data}, '$.item.parentToolCallId')`; + const parentToolCallId = sql`COALESCE( + json_extract(${events.data}, '$.item.parentToolCallId'), + json_extract(${events.data}, '$.parentToolCallId') + )`; const conditions: SQL[] = [ eq(events.threadId, args.threadId), - or( - inArray(eventParentToolCallId, parentToolCallIds), - inArray(itemParentToolCallId, parentToolCallIds), - )!, + eq(events.itemKind, "toolCall"), + isNotNull(events.itemId), + isNotNull(events.turnId), + inArray(parentToolCallId, parentToolCallIds), ]; - if (args.excludedTypes && args.excludedTypes.length > 0) { - conditions.push(notInArray(events.type, [...args.excludedTypes])); + if (args.sequenceStart !== undefined) { + conditions.push(gte(events.sequence, args.sequenceStart)); + } + if (args.sequenceEnd !== undefined) { + conditions.push(lte(events.sequence, args.sequenceEnd)); } return db - .select(storedEventRowFields) + .select({ itemId: events.itemId, turnId: events.turnId }) .from(events) .where(and(...conditions)) - .orderBy(events.sequence) - .all(); + .groupBy(events.itemId, events.turnId) + .all() + .flatMap((row) => + row.itemId === null || row.turnId === null + ? [] + : [{ itemId: row.itemId, turnId: row.turnId }], + ); } export function listStoredToolCallRowsByItemIds( db: DbConnection, args: ListStoredToolCallRowsByItemIdsArgs, -): StoredEventRow[] { +): BoundedStoredEventRowsResult { const itemIds = [...new Set(args.itemIds)].filter( (itemId) => itemId.length > 0, ); if (itemIds.length === 0) { - return []; + return { dataBytes: 0, hasMore: false, rows: [] }; + } + + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + inArray(events.itemId, itemIds), + eq(events.itemKind, "toolCall"), + inArray(events.type, ["item/started", "item/completed"]), + ]; + if (args.sequenceEnd !== undefined) { + conditions.push(lte(events.sequence, args.sequenceEnd)); + } + + const result = listBoundedStoredEventRows(db, { + condition: and(...conditions), + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); + return { ...result, rows: result.rows.reverse() }; +} + +export function listStoredItemStartedRowsByItemIds( + db: DbConnection, + args: ListStoredItemStartedRowsByItemIdsArgs, +): BoundedStoredEventRowsResult { + const itemIds = [...new Set(args.itemIds)].filter( + (itemId) => itemId.length > 0, + ); + if (itemIds.length === 0) { + return { dataBytes: 0, hasMore: false, rows: [] }; + } + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + eq(events.type, "item/started"), + inArray(events.itemId, itemIds), + ]; + if (args.sequenceCutoff !== undefined) { + conditions.push(lte(events.sequence, args.sequenceCutoff)); } + const result = listBoundedStoredEventRows(db, { + condition: and(...conditions), + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); + return { ...result, rows: result.rows.reverse() }; +} +export function listStoredItemLifecycleOwnerSequences( + db: DbConnection, + args: ListStoredItemLifecycleOwnerSequencesArgs, +): StoredItemLifecycleOwnerSequence[] { + const itemIds = [...new Set(args.itemIds)].filter( + (itemId) => itemId.length > 0, + ); + if (itemIds.length === 0 || args.seqStart > args.seqEnd) { + return []; + } return db - .select(storedEventRowFields) + .select({ itemId: events.itemId, sequence: max(events.sequence) }) .from(events) .where( and( eq(events.threadId, args.threadId), + isNotNull(events.itemId), inArray(events.itemId, itemIds), - eq(events.itemKind, "toolCall"), inArray(events.type, ["item/started", "item/completed"]), + gte(events.sequence, args.seqStart), + lte(events.sequence, args.seqEnd), ), ) - .orderBy(events.sequence) - .all(); + .groupBy(events.itemId) + .all() + .flatMap((row) => + row.itemId === null || row.sequence === null + ? [] + : [{ itemId: row.itemId, sequence: row.sequence }], + ); } export function listStoredClientTurnRequestIdsInRange( @@ -1238,9 +2680,9 @@ export function getStoredTurnRequestEventForTurn( export function listStoredTurnInputAcceptedRowsByClientRequestIds( db: DbConnection, args: ListStoredTurnInputAcceptedRowsByClientRequestIdsArgs, -): StoredEventRow[] { +): BoundedStoredEventRowsResult { if (args.clientRequestIds.length === 0) { - return []; + return { dataBytes: 0, hasMore: false, rows: [] }; } const clientRequestIdConditions = args.clientRequestIds.map( @@ -1248,19 +2690,21 @@ export function listStoredTurnInputAcceptedRowsByClientRequestIds( sql`json_extract(${events.data}, '$.clientRequestId') = ${clientRequestId}`, ); - return db - .select(storedEventRowFields) - .from(events) - .where( - and( + const result = listBoundedStoredEventRows(db, { + condition: and( eq(events.threadId, args.threadId), eq(events.type, "turn/input/accepted"), gt(events.sequence, args.afterSequence), + ...(args.beforeOrAtSequence === undefined + ? [] + : [lte(events.sequence, args.beforeOrAtSequence)]), or(...clientRequestIdConditions), ), - ) - .orderBy(events.sequence) - .all(); + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); + return { ...result, rows: result.rows.reverse() }; } export function listStoredThreadProvisioningRowsByProvisioningId( @@ -1308,32 +2752,40 @@ export function getLatestThreadInterruptedReason( export function listStoredTurnStartedRowsByTurnIdsUpToSequence( db: DbConnection, args: ListStoredTurnStartedRowsByTurnIdsUpToSequenceArgs, -): StoredEventRow[] { +): BoundedStoredEventRowsResult { if (args.turnIds.length === 0) { - return []; + return { dataBytes: 0, hasMore: false, rows: [] }; } - return db - .select(storedEventRowFields) - .from(events) - .where( - and( + const result = listBoundedStoredEventRows(db, { + condition: and( eq(events.threadId, args.threadId), eq(events.type, "turn/started"), inArray(events.turnId, [...args.turnIds]), lte(events.sequence, args.sequenceCutoff), ), - ) - .orderBy(events.sequence) - .all(); + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); + return { ...result, rows: result.rows.reverse() }; } export interface ListLatestBackgroundTaskStateRowsByItemIdsArgs { + excludedRowIds?: readonly string[]; itemIds: readonly string[]; + limit: number; + maxBytes: number; + maxDataBytes?: number; + sequenceCutoff?: number; threadId: string; } export interface ListLatestOpenBackgroundTaskStateRowsForThreadArgs { + excludedRowIds?: readonly string[]; + limit: number; + maxBytes: number; + maxDataBytes?: number; threadId: string; } @@ -1349,17 +2801,16 @@ export interface ActiveBackgroundTaskCountRow { } /** - * Latest thread-scoped lifecycle row per backgroundTask item, regardless of - * sequence. Timeline windows backfill these for in-window items so a page - * containing only the spawning turn still renders the task's current/terminal - * state (which may live many sequences past the window's end). + * Latest thread-scoped lifecycle row per backgroundTask item, optionally + * bounded by a captured timeline range. Live windows omit the cutoff and see + * current state; immutable completed-detail ranges supply it. */ export function listLatestBackgroundTaskStateRowsByItemIds( db: DbConnection, args: ListLatestBackgroundTaskStateRowsByItemIdsArgs, -): StoredEventRow[] { +): BoundedStoredEventRowsResult { if (args.itemIds.length === 0) { - return []; + return { dataBytes: 0, hasMore: false, rows: [] }; } const stateTypes = [ @@ -1371,11 +2822,8 @@ export function listLatestBackgroundTaskStateRowsByItemIds( // (threadId, sequence) is unique, so matching the per-item MAX(sequence) // set selects exactly one row per item in SQL instead of loading every // snapshot row and folding in JS. - return db - .select(storedEventRowFields) - .from(events) - .where( - and( + const result = listBoundedStoredEventRows(db, { + condition: and( eq(events.threadId, args.threadId), inArray( events.sequence, @@ -1387,14 +2835,20 @@ export function listLatestBackgroundTaskStateRowsByItemIds( eq(latest.threadId, args.threadId), inArray(latest.itemId, [...args.itemIds]), inArray(latest.type, stateTypes), + ...(args.sequenceCutoff === undefined + ? [] + : [lte(latest.sequence, args.sequenceCutoff)]), ), ) .groupBy(latest.itemId), ), ), - ) - .orderBy(events.sequence) - .all(); + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + maxDataBytes: args.maxDataBytes, + }); + return { ...result, rows: result.rows.reverse() }; } /** @@ -1406,7 +2860,7 @@ export function listLatestBackgroundTaskStateRowsByItemIds( export function listLatestOpenBackgroundTaskStateRowsForThread( db: DbConnection, args: ListLatestOpenBackgroundTaskStateRowsForThreadArgs, -): StoredEventRow[] { +): BoundedStoredEventRowsResult { const startedType = "item/started" satisfies ThreadEventType; const progressType = "item/backgroundTask/progress" satisfies ThreadEventType; @@ -1414,11 +2868,8 @@ export function listLatestOpenBackgroundTaskStateRowsForThread( "item/backgroundTask/completed" satisfies ThreadEventType; const completed = alias(events, "completed_background_task_state"); - return db - .select(storedEventRowFields) - .from(events) - .where( - and( + const result = listBoundedStoredEventRows(db, { + condition: and( eq(events.threadId, args.threadId), eq(events.itemKind, "backgroundTask"), inArray(events.type, [startedType, progressType]), @@ -1444,9 +2895,12 @@ export function listLatestOpenBackgroundTaskStateRowsForThread( AND latest.type IN (${startedType}, ${progressType}) )`, ), - ) - .orderBy(events.sequence) - .all(); + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + maxDataBytes: args.maxDataBytes, + }); + return { ...result, rows: result.rows.reverse() }; } /** @@ -1654,6 +3108,50 @@ export function listRecentStoredEventRows( .all(); } +/** + * The conversation outline renders only user/assistant messages. Selecting + * command, tool, diff, goal, and usage events made its cost scale with all work + * performed in a thread even though none of those rows can reach the result. + */ +export function listStoredConversationOutlineEventRows( + db: DbConnection, + args: ListStoredConversationOutlineEventRowsArgs, +): StoredEventRow[] { + const directConversationTypes = [ + "client/turn/requested", + "turn/input/accepted", + "turn/started", + "turn/completed", + "item/agentMessage/delta", + "system/manager/user_message", + ] satisfies ThreadEventType[]; + const agentItemTypes = [ + "item/started", + "item/completed", + ] satisfies ThreadEventType[]; + + return db + .select(storedEventRowFields) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + or( + inArray(events.type, directConversationTypes), + and( + inArray(events.type, agentItemTypes), + or( + eq(events.itemKind, "agentMessage"), + sql`json_extract(${events.data}, '$.item.type') = 'agentMessage'`, + ), + ), + ), + ), + ) + .orderBy(events.sequence) + .all(); +} + export interface StandardTimelineSegmentAnchorRow { rowId: string; sequence: number; @@ -1787,65 +3285,85 @@ export function listStoredTimelineWindowEventRows( .all(); } -function listLatestRowsForContextWindowUsage( +/** + * Reads only the newest part of a timeline sequence range. Callers use the + * descending order to enforce a byte budget before decoding event JSON. + */ +export function listStoredTimelineWindowEventRowsDescending( db: DbConnection, - args: { - contextWindowJsonPath: string; - eventType: - | "thread/contextWindowUsage/updated" - | "thread/tokenUsage/updated"; - threadId: string; - }, -): StoredEventRow[] { - const latestRow = db - .select(storedEventRowFields) - .from(events) - .where( - and( - eq(events.threadId, args.threadId), - eq(events.type, args.eventType), - isNotNestedTurnUsageEvent, - ), - ) - .orderBy(desc(events.sequence)) - .limit(1) - .get(); - - if (!latestRow) { - return []; + args: ListStoredTimelineWindowEventRowsDescendingArgs, +): BoundedStoredEventRowsResult { + const conditions: SQL[] = [ + eq(events.threadId, args.threadId), + gte(events.sequence, args.sequenceStart), + ]; + if (args.beforeSequence !== undefined) { + conditions.push(lt(events.sequence, args.beforeSequence)); } - - const latestContextRow = db - .select(storedEventRowFields) - .from(events) - .where( - and( - eq(events.threadId, args.threadId), - eq(events.type, args.eventType), - isNotNestedTurnUsageEvent, - sql`json_extract(${events.data}, ${args.contextWindowJsonPath}) IS NOT NULL`, - ), - ) - .orderBy(desc(events.sequence)) - .limit(1) - .get(); - - if (!latestContextRow || latestContextRow.id === latestRow.id) { - return [latestRow]; + if (args.excludedTypes && args.excludedTypes.length > 0) { + conditions.push(notInArray(events.type, [...args.excludedTypes])); } - return [latestContextRow, latestRow]; + return listBoundedStoredEventRows(db, { + condition: and(...conditions), + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, + }); } export function listContextWindowUsageRows( db: DbConnection, args: ListContextWindowUsageRowsArgs, -): StoredEventRow[] { - return listLatestRowsForContextWindowUsage(db, { - threadId: args.threadId, - eventType: "thread/contextWindowUsage/updated", - contextWindowJsonPath: "$.contextWindowUsage.modelContextWindow", +): BoundedStoredEventRowsResult { + const eventType = + "thread/contextWindowUsage/updated" satisfies ThreadEventType; + const condition = and( + eq(events.threadId, args.threadId), + eq(events.type, eventType), + isNotNestedTurnUsageEvent, + or( + sql`${events.sequence} = ( + SELECT MAX(latest.sequence) + FROM events latest + WHERE latest.thread_id = ${args.threadId} + AND latest.type = ${eventType} + AND NOT EXISTS ( + SELECT 1 + FROM events AS nested_turn_started + WHERE nested_turn_started.thread_id = latest.thread_id + AND nested_turn_started.turn_id = latest.turn_id + AND nested_turn_started.type = 'turn/started' + AND COALESCE(json_extract(nested_turn_started.data, '$.parentToolCallId'), '') <> '' + ) + )`, + sql`${events.sequence} = ( + SELECT MAX(latest.sequence) + FROM events latest + WHERE latest.thread_id = ${args.threadId} + AND latest.type = ${eventType} + AND json_extract( + latest.data, + '$.contextWindowUsage.modelContextWindow' + ) IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM events AS nested_turn_started + WHERE nested_turn_started.thread_id = latest.thread_id + AND nested_turn_started.turn_id = latest.turn_id + AND nested_turn_started.type = 'turn/started' + AND COALESCE(json_extract(nested_turn_started.data, '$.parentToolCallId'), '') <> '' + ) + )`, + ), + ); + const result = listBoundedStoredEventRows(db, { + condition, + excludedRowIds: args.excludedRowIds, + limit: args.limit, + maxBytes: args.maxBytes, }); + return { ...result, rows: result.rows.reverse() }; } export function getLatestThreadOutputEventRow( diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index dbb9422ab8..7f661bc918 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -309,6 +309,7 @@ export { getLastStoredTurnRequestEvent, getStoredTurnRequestEventForTurn, getLatestThreadOutputEventRow, + getStoredEventIdentityAtSequence, getLatestThreadSystemErrorEventRow, getLatestThreadSequence, insertEvents, @@ -320,20 +321,33 @@ export { listTimelineSegmentAnchorsDescending, findTimelineSegmentAnchorSequenceAfter, getTimelineSegmentAnchorAtSequence, + hasStoredTurnEventInRange, listStoredClientTurnRequestIdsInRange, listStoredClientTurnRequestRowsByKeys, listStoredEventRowsByParentToolCallIds, + listStoredDelegatedTurnDescendantRanges, + listStoredDelegationChildTurnRanges, + listStoredNonemptyDelegationChildTurnBucketIndexes, + listStoredDelegationDescendantRanges, + listStoredTurnDescendantRanges, + listStoredParentedToolCalls, + listStoredParentedTurnRanges, listStoredEventRowsByThreadIdsAndTypes, listStoredEventRows, listStoredEventRowsInRange, listStoredThreadProvisioningRowsByProvisioningId, + listStoredConversationOutlineEventRows, listStoredTimelineWindowEventRows, + listStoredTimelineWindowEventRowsDescending, listStoredToolCallRowsByItemIds, + listStoredItemStartedRowsByItemIds, + listStoredItemLifecycleOwnerSequences, listStoredTurnInputAcceptedRowsByClientRequestIds, listStoredTurnStartedKeys, listStoredTurnStartedRowsByTurnIdsUpToSequence, getLatestThreadInterruptedReason, listLatestGoalEventRowsByThreadIds, + listLatestGoalEventRowsForThread, listLatestBackgroundTaskStateRowsByItemIds, listLatestOpenBackgroundTaskStateRowsForThread, listOpenTurnInputAcceptedRowsByThreadIds, @@ -353,9 +367,11 @@ export type { AppendDaemonEventInput, AppendDaemonEventsResult, AppendStoredThreadEventArgs, + BoundedStoredEventRowsResult, CompletedStoredTurnRow, FindStoredClientTurnRequestSequenceByRequestIdArgs, GetStoredTurnRequestEventForTurnArgs, + GetStoredEventIdentityAtSequenceArgs, GetLatestThreadInterruptedReasonArgs, GetLatestThreadSequenceArgs, HasStoredTurnStartedArgs, @@ -363,7 +379,12 @@ export type { InsertEventsResult, ListActiveBackgroundTaskCountsByThreadIdsArgs, ListEventsOptions, + ListStoredDelegatedTurnDescendantRangesArgs, + ListStoredDelegationChildTurnRangesArgs, + ListStoredNonemptyDelegationChildTurnBucketIndexesArgs, + ListStoredDelegationDescendantRangesArgs, ListLatestGoalEventRowsByThreadIdsArgs, + ListLatestGoalEventRowsForThreadArgs, ListLatestOpenBackgroundTaskStateRowsForThreadArgs, ListTimelineSegmentAnchorsDescendingArgs, TimelineSegmentAnchorLookupArgs, @@ -386,6 +407,11 @@ export type { PruneResolvedItemDeltasArgs, PruneThreadEventsBeforeSequenceArgs, StoredEventRow, + StoredEventIdentity, + StoredDelegatedTurnDescendantRange, + StoredDelegationDescendantRange, + StoredParentedToolCall, + StoredParentedTurnRange, StandardTimelineSegmentAnchorRow, ThreadClientTurnRequestKey, ThreadTurnKey, diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index a898c94c39..6fc149c6e8 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -29,6 +29,7 @@ import { listCompletedTurnsByThreadIds, listEvents, listLatestGoalEventRowsByThreadIds, + listLatestGoalEventRowsForThread, listRecentStoredEventRows, listTimelineSegmentAnchorsDescending, findTimelineSegmentAnchorSequenceAfter, @@ -37,10 +38,21 @@ import { listStoredClientTurnRequestIdsInRange, listStoredClientTurnRequestRowsByKeys, listStoredEventRows, + listStoredEventRowsByParentToolCallIds, listStoredEventRowsInRange, + listStoredDelegatedTurnDescendantRanges, + listStoredDelegationChildTurnRanges, + listStoredDelegationDescendantRanges, + listStoredNonemptyDelegationChildTurnBucketIndexes, + listStoredTurnDescendantRanges, + listStoredItemStartedRowsByItemIds, + listStoredToolCallRowsByItemIds, + listStoredConversationOutlineEventRows, listStoredThreadProvisioningRowsByProvisioningId, listStoredTimelineWindowEventRows, + listStoredTimelineWindowEventRowsDescending, listStoredTurnInputAcceptedRowsByClientRequestIds, + listStoredTurnStartedRowsByTurnIdsUpToSequence, MissingStoredTurnStartedError, listActiveBackgroundTaskCountsByThreadIds, listLatestBackgroundTaskStateRowsByItemIds, @@ -52,6 +64,7 @@ import { pruneResolvedItemDeltas, pruneThreadEventsBeforeSequence, listLatestOpenBackgroundTaskStateRowsForThread, + type StoredEventRow, } from "../../src/data/events.js"; import { createEnvironment } from "../../src/data/environments.js"; import { createProject } from "../../src/data/projects.js"; @@ -107,6 +120,30 @@ function textInput(text: string): PromptInput[] { return [{ type: "text", text, mentions: [] }]; } +function storedEventRowTextBytes(row: { + data: string; + id: string; + itemId: string | null; + itemKind: string | null; + providerThreadId: string | null; + scopeKind: string; + threadId: string; + turnId: string | null; + type: string; +}): number { + return [ + row.data, + row.id, + row.itemId ?? "", + row.itemKind ?? "", + row.providerThreadId ?? "", + row.scopeKind, + row.threadId, + row.turnId ?? "", + row.type, + ].reduce((bytes, value) => bytes + Buffer.byteLength(value, "utf8"), 0); +} + function clientTurnRequestData(requestId: string, text: string): string { return JSON.stringify({ direction: "outbound", @@ -172,6 +209,122 @@ function createContextWindowUsageData( } describe("events", () => { + it("caps restored lifecycle context at a captured sequence", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "item/started", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread", + itemId: "parent-tool", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "parent-tool", + tool: "Agent", + arguments: { prompt: "work", subagent_type: "general-purpose" }, + status: "pending", + }, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "item/completed", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread", + itemId: "parent-tool", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "parent-tool", + tool: "Agent", + arguments: { prompt: "work", subagent_type: "general-purpose" }, + result: "done", + status: "completed", + }, + }), + }, + { + threadId: thread.id, + sequence: 3, + type: "item/started", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread", + itemId: "reused-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "reused-command", + command: "first", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread", + itemId: "reused-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "reused-command", + command: "future", + cwd: "/repo", + status: "pending", + approvalStatus: null, + }, + }), + }, + ]); + + expect( + listStoredToolCallRowsByItemIds(db, { + itemIds: ["parent-tool"], + limit: 10, + maxBytes: 1_000_000, + sequenceEnd: 1, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([1]); + expect( + listStoredToolCallRowsByItemIds(db, { + itemIds: ["parent-tool"], + limit: 10, + maxBytes: 1_000_000, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([1, 2]); + expect( + listStoredItemStartedRowsByItemIds(db, { + itemIds: ["reused-command"], + limit: 10, + maxBytes: 1_000_000, + sequenceCutoff: 3, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([3]); + expect( + listStoredItemStartedRowsByItemIds(db, { + itemIds: ["reused-command"], + limit: 10, + maxBytes: 1_000_000, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([3, 4]); + }); + it("inserts events and returns count", () => { const { db, thread } = setup(); @@ -801,8 +954,10 @@ describe("events", () => { expect( listContextWindowUsageRows(db, { + limit: 2, + maxBytes: 32_000, threadId: thread.id, - }).map((row) => row.sequence), + }).rows.map((row) => row.sequence), ).toEqual([2, 3]); }); @@ -858,8 +1013,10 @@ describe("events", () => { expect( listContextWindowUsageRows(db, { + limit: 2, + maxBytes: 32_000, threadId: thread.id, - }).map((row) => row.sequence), + }).rows.map((row) => row.sequence), ).toEqual([2, 5]); }); @@ -1094,29 +1251,492 @@ describe("events", () => { ]); expect( - listStoredTimelineWindowEventRows(db, { - beforeSequence: 4, - excludedTypes: ["thread/contextWindowUsage/updated"], - sequenceStart: 2, - threadId: thread.id, - }).map((row) => row.sequence), - ).toEqual([3]); - - expect( - listStoredTimelineWindowEventRows(db, { - excludedTypes: [], - sequenceStart: 2, - threadId: thread.id, - }).map((row) => row.sequence), - ).toEqual([2, 3, 4]); - - expect( - listStoredTimelineWindowEventRows(db, { - beforeSequence: 4, - sequenceStart: 2, + listStoredTimelineWindowEventRows(db, { + beforeSequence: 4, + excludedTypes: ["thread/contextWindowUsage/updated"], + sequenceStart: 2, + threadId: thread.id, + }).map((row) => row.sequence), + ).toEqual([3]); + + expect( + listStoredTimelineWindowEventRows(db, { + excludedTypes: [], + sequenceStart: 2, + threadId: thread.id, + }).map((row) => row.sequence), + ).toEqual([2, 3, 4]); + + expect( + listStoredTimelineWindowEventRows(db, { + beforeSequence: 4, + sequenceStart: 2, + threadId: thread.id, + }).map((row) => row.sequence), + ).toEqual([2, 3]); + + expect( + listStoredTimelineWindowEventRowsDescending(db, { + excludedTypes: [], + limit: 2, + maxBytes: 750_000, + sequenceStart: 2, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([4, 3]); + }); + + it("returns a bounded representation for an oversized timeline event", () => { + const { db, thread } = setup(); + const oversizedItemId = "item".repeat(1_000); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "item/commandExecution/outputDelta", + ...createTurnEventFields({ turnId: "turn-1" }), + itemId: "command-1", + itemKind: "commandExecution", + data: JSON.stringify({ + itemId: "command-1", + delta: '💥\u0000\\"'.repeat(50_000), + reset: true, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "item/commandExecution/outputDelta", + ...createTurnEventFields({ turnId: "turn-1" }), + itemId: oversizedItemId, + itemKind: "commandExecution", + data: JSON.stringify({ + itemId: oversizedItemId, + delta: "x".repeat(50_000), + }), + }, + ]); + + const [oversizedIdentityRow, row] = + listStoredTimelineWindowEventRowsDescending(db, { + excludedTypes: [], + limit: 2, + maxBytes: 1_000, + sequenceStart: 1, + threadId: thread.id, + }).rows; + + expect(row?.type).toBe("item/commandExecution/outputDelta"); + expect(Buffer.byteLength(row?.data ?? "", "utf8")).toBeLessThanOrEqual( + 1_000, + ); + expect(JSON.parse(row?.data ?? "{}")).toMatchObject({ + itemId: "command-1", + reset: true, + delta: expect.stringContaining( + "oversized event truncated for timeline rendering", + ), + }); + expect(oversizedIdentityRow).toMatchObject({ + itemId: null, + itemKind: null, + type: "system/error", + }); + expect( + Buffer.byteLength(oversizedIdentityRow?.data ?? "", "utf8"), + ).toBeLessThanOrEqual(1_000); + }); + + it("pages past oversized provider and turn identities without losing events", () => { + const { db, thread } = setup(); + const oversizedProviderThreadId = `provider-${"界".repeat(2_000)}`; + const oversizedTurnId = `turn-${"🧭".repeat(2_000)}`; + insertEvents(db, noopNotifier, [ + ...Array.from({ length: 4 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "system/error" as const, + ...threadEventFields, + data: JSON.stringify({ message: `bounded-${index}-${"x".repeat(280)}` }), + })), + { + threadId: thread.id, + sequence: 5, + type: "system/error", + ...threadEventFields, + providerThreadId: oversizedProviderThreadId, + data: JSON.stringify({ message: "oversized provider identity" }), + }, + { + threadId: thread.id, + sequence: 6, + type: "system/error", + ...createTurnEventFields({ turnId: oversizedTurnId }), + data: JSON.stringify({ message: "oversized turn identity" }), + }, + ]); + + const storedRowsBySequence = new Map( + listStoredEventRows(db, { threadId: thread.id }).map((row) => [ + row.sequence, + row, + ]), + ); + const sequences: number[] = []; + const projectedRows = new Map(); + let beforeSequence: number | undefined; + while (true) { + const page = listStoredTimelineWindowEventRowsDescending(db, { + ...(beforeSequence === undefined ? {} : { beforeSequence }), + excludedTypes: [], + limit: 10, + maxBytes: 700, + sequenceStart: 1, + threadId: thread.id, + }); + + expect(page.rows.length).toBeGreaterThan(0); + expect(page.dataBytes).toBeLessThanOrEqual(700); + for (const row of page.rows) { + sequences.push(row.sequence); + projectedRows.set(row.sequence, row); + } + if (!page.hasMore) break; + beforeSequence = page.rows.at(-1)?.sequence; + expect(beforeSequence).toBeDefined(); + } + + expect(sequences).toEqual([6, 5, 4, 3, 2, 1]); + expect(new Set(sequences).size).toBe(sequences.length); + for (const sequence of [5, 6]) { + const projected = projectedRows.get(sequence); + const stored = storedRowsBySequence.get(sequence); + expect(projected).toMatchObject({ + id: stored?.id, + providerThreadId: null, + scopeKind: "thread", + sequence, + threadId: thread.id, + turnId: null, + type: "system/error", + }); + expect(JSON.parse(projected?.data ?? "{}")).toMatchObject({ + code: "timeline_event_identity_too_large", + }); + } + }); + + it("enforces cumulative UTF-8 bytes across all returned text columns", () => { + const { db, thread } = setup(); + insertEvents( + db, + noopNotifier, + Array.from({ length: 4 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "system/error" as const, + ...threadEventFields, + providerThreadId: `provider-🧭-${index}`, + data: JSON.stringify({ message: `界`.repeat(90 + index) }), + })), + ); + + const newestRows = listStoredEventRows(db, { + threadId: thread.id, + }).reverse(); + const maxBytes = newestRows + .slice(0, 2) + .reduce((bytes, row) => bytes + storedEventRowTextBytes(row), 0); + const result = listStoredTimelineWindowEventRowsDescending(db, { + excludedTypes: [], + limit: 4, + maxBytes, + sequenceStart: 1, + threadId: thread.id, + }); + + expect(result.rows.map((row) => row.sequence)).toEqual([4, 3]); + expect(result.dataBytes).toBe(maxBytes); + expect(result.hasMore).toBe(true); + expect( + result.rows.reduce( + (bytes, row) => bytes + storedEventRowTextBytes(row), + 0, + ), + ).toBe(result.dataBytes); + }); + + it("keyset-pages a row-limit sentinel without gaps or duplicates", () => { + const { db, thread } = setup(); + insertEvents( + db, + noopNotifier, + Array.from({ length: 7 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "system/error" as const, + ...threadEventFields, + data: JSON.stringify({ message: `event-${index + 1}` }), + })), + ); + + const sequences: number[] = []; + let beforeSequence: number | undefined; + while (true) { + const page = listStoredTimelineWindowEventRowsDescending(db, { + ...(beforeSequence === undefined ? {} : { beforeSequence }), + excludedTypes: [], + limit: 2, + maxBytes: 100_000, + sequenceStart: 1, + threadId: thread.id, + }); + sequences.push(...page.rows.map((row) => row.sequence)); + if (!page.hasMore) break; + beforeSequence = page.rows.at(-1)?.sequence; + expect(beforeSequence).toBeDefined(); + } + + expect(sequences).toEqual([7, 6, 5, 4, 3, 2, 1]); + expect(new Set(sequences).size).toBe(sequences.length); + }); + + it("returns an oversized newest placeholder and advances past it", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "system/error", + ...threadEventFields, + data: JSON.stringify({ message: "older" }), + }, + { + threadId: thread.id, + sequence: 2, + type: "system/error", + ...threadEventFields, + data: JSON.stringify({ message: "x".repeat(100_000) }), + }, + ]); + + const newest = listStoredTimelineWindowEventRowsDescending(db, { + excludedTypes: [], + limit: 1, + maxBytes: 1_000, + sequenceStart: 1, + threadId: thread.id, + }); + expect(newest.rows).toHaveLength(1); + expect(newest.rows[0]).toMatchObject({ + sequence: 2, + type: "system/error", + }); + expect(JSON.parse(newest.rows[0]?.data ?? "{}")).toMatchObject({ + code: "timeline_event_payload_too_large", + }); + expect(newest.dataBytes).toBeLessThanOrEqual(1_000); + expect(newest.hasMore).toBe(true); + + const older = listStoredTimelineWindowEventRowsDescending(db, { + beforeSequence: 2, + excludedTypes: [], + limit: 1, + maxBytes: 1_000, + sequenceStart: 1, + threadId: thread.id, + }); + expect(older.rows.map((row) => row.sequence)).toEqual([1]); + expect(older.hasMore).toBe(false); + }); + + it("excludes existing enrichment ids before applying its SQL budget", () => { + const { db, thread } = setup(); + insertEvents( + db, + noopNotifier, + Array.from({ length: 3 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "system/error" as const, + ...threadEventFields, + data: JSON.stringify({ + parentToolCallId: "parent-tool", + message: `child-${index + 1}-${"界".repeat(40)}`, + }), + })), + ); + + const first = listStoredEventRowsByParentToolCallIds(db, { + limit: 1, + maxBytes: 10_000, + parentToolCallIds: ["parent-tool"], + threadId: thread.id, + }); + const second = listStoredEventRowsByParentToolCallIds(db, { + excludedRowIds: first.rows.map((row) => row.id), + limit: 1, + maxBytes: first.dataBytes, + parentToolCallIds: ["parent-tool"], + threadId: thread.id, + }); + + expect(first.rows.map((row) => row.sequence)).toEqual([3]); + expect(second.rows.map((row) => row.sequence)).toEqual([2]); + expect(second.dataBytes).toBeLessThanOrEqual(first.dataBytes); + expect(second.rows).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: first.rows[0]?.id })]), + ); + }); + + it("keeps lifecycle enrichment type-aware within each requested budget", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "turn/started", + ...createTurnEventFields({ turnId: "turn-1" }), + data: JSON.stringify({ filler: "界".repeat(10_000) }), + }, + { + threadId: thread.id, + sequence: 2, + type: "item/started", + ...createTurnEventFields({ turnId: "turn-1" }), + itemId: "parent-tool", + itemKind: "toolCall", + data: JSON.stringify({ + item: { + type: "toolCall", + id: "parent-tool", + tool: "delegate", + status: "pending", + arguments: { prompt: "x".repeat(50_000) }, + }, + }), + }, + { + threadId: thread.id, + sequence: 3, + type: "item/started", + ...createTurnEventFields({ turnId: "turn-1" }), + itemId: "child-command", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { + type: "commandExecution", + id: "child-command", + command: "x".repeat(50_000), + cwd: "/repo", + status: "pending", + approvalStatus: null, + parentToolCallId: "parent-tool", + }, + }), + }, + ]); + + const maxBytes = 2_000; + const parent = listStoredToolCallRowsByItemIds(db, { + itemIds: ["parent-tool"], + limit: 2, + maxBytes, + threadId: thread.id, + }); + const itemStart = listStoredItemStartedRowsByItemIds(db, { + itemIds: ["child-command"], + limit: 1, + maxBytes, + threadId: thread.id, + }); + const turnStart = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + limit: 1, + maxBytes, + sequenceCutoff: 3, + threadId: thread.id, + turnIds: ["turn-1"], + }); + + expect(parent.rows[0]).toMatchObject({ + itemId: "parent-tool", + itemKind: "toolCall", + type: "item/started", + }); + expect(itemStart.rows[0]).toMatchObject({ + itemId: "child-command", + itemKind: "commandExecution", + type: "item/started", + }); + expect(turnStart.rows[0]).toMatchObject({ + turnId: "turn-1", + type: "turn/started", + }); + for (const result of [parent, itemStart, turnStart]) { + expect(result.rows).toHaveLength(1); + expect(result.dataBytes).toBeLessThanOrEqual(maxBytes); + expect( + result.rows.reduce( + (bytes, row) => bytes + storedEventRowTextBytes(row), + 0, + ), + ).toBe(result.dataBytes); + } + }); + + it("loads only message-producing events for conversation outlines", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + ...threadEventFields, + data: JSON.stringify({ input: textInput("question") }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + ...threadEventFields, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "item/completed", + ...threadEventFields, + itemId: "command-1", + itemKind: "commandExecution", + data: JSON.stringify({ + item: { type: "commandExecution", id: "command-1" }, + }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/completed", + ...threadEventFields, + itemId: "message-1", + itemKind: "agentMessage", + data: JSON.stringify({ + item: { type: "agentMessage", id: "message-1", text: "answer" }, + }), + }, + { + threadId: thread.id, + sequence: 5, + type: "turn/completed", + ...threadEventFields, + data: JSON.stringify({ status: "completed" }), + }, + ]); + + expect( + listStoredConversationOutlineEventRows(db, { threadId: thread.id, }).map((row) => row.sequence), - ).toEqual([2, 3]); + ).toEqual([1, 2, 4, 5]); }); it("lists accepted input rows for requested client turn sequences", () => { @@ -1174,6 +1794,7 @@ describe("events", () => { ...createTurnEventFields({ turnId: "turn-1" }), data: JSON.stringify({ clientRequestId: "creq_23456789ab", + ignoredLargeField: "x".repeat(50_000), }), }, { @@ -1201,8 +1822,34 @@ describe("events", () => { threadId: thread.id, afterSequence: 2, clientRequestIds: ["creq_23456789ab", "creq_23456789ac"], - }).map((row) => row.sequence), + limit: 10, + maxBytes: 10_000, + }).rows.map((row) => row.sequence), ).toEqual([3, 5]); + expect( + listStoredTurnInputAcceptedRowsByClientRequestIds(db, { + afterSequence: 2, + beforeOrAtSequence: 4, + clientRequestIds: ["creq_23456789ab", "creq_23456789ac"], + limit: 10, + maxBytes: 10_000, + threadId: thread.id, + }).rows.map((row) => row.sequence), + ).toEqual([3]); + const [boundedAccepted] = + listStoredTurnInputAcceptedRowsByClientRequestIds(db, { + afterSequence: 2, + clientRequestIds: ["creq_23456789ab"], + limit: 10, + maxBytes: 1_000, + threadId: thread.id, + }).rows; + expect(Buffer.byteLength(boundedAccepted?.data ?? "", "utf8")).toBeLessThanOrEqual( + 1_000, + ); + expect(JSON.parse(boundedAccepted?.data ?? "{}")).toEqual({ + clientRequestId: "creq_23456789ab", + }); }); it("lists only the latest goal event row per thread", () => { @@ -1242,7 +1889,7 @@ describe("events", () => { ...threadEventFields, providerThreadId: "provider-thread-2", data: JSON.stringify({ - objective: "Active goal", + objective: `Active goal ${"界".repeat(10_000)}`, status: "active", tokenBudget: null, tokensUsed: 2, @@ -1263,6 +1910,19 @@ describe("events", () => { "thread/goal/updated", ); expect(rowsByThreadId.get(otherThread.id)?.sequence).toBe(1); + + const boundedGoal = listLatestGoalEventRowsForThread(db, { + limit: 1, + maxBytes: 2_000, + threadId: otherThread.id, + }); + expect(boundedGoal.dataBytes).toBeLessThanOrEqual(2_000); + expect(boundedGoal.rows[0]?.type).toBe("thread/goal/updated"); + expect(JSON.parse(boundedGoal.rows[0]?.data ?? "{}")).toMatchObject({ + objective: expect.stringContaining("large goal details omitted"), + status: "active", + tokensUsed: 2, + }); }); it("lists only open accepted turn inputs after the latest interruption", () => { @@ -3143,7 +3803,9 @@ describe("events", () => { const rows = listLatestBackgroundTaskStateRowsByItemIds(db, { threadId: thread.id, itemIds: ["task:wf-1", "task:wf-2"], - }); + limit: 10, + maxBytes: 100_000, + }).rows; expect( rows.map((row) => ({ @@ -3168,8 +3830,10 @@ describe("events", () => { listLatestBackgroundTaskStateRowsByItemIds(db, { threadId: thread.id, itemIds: [], + limit: 10, + maxBytes: 100_000, }), - ).toEqual([]); + ).toEqual({ dataBytes: 0, hasMore: false, rows: [] }); }); it("returns latest non-terminal open backgroundTask state rows for a thread", () => { @@ -3341,8 +4005,11 @@ describe("events", () => { ]); const rows = listLatestOpenBackgroundTaskStateRowsForThread(db, { + limit: 16, + maxBytes: 256_000, + maxDataBytes: 16_000, threadId: thread.id, - }); + }).rows; expect( rows.map((row) => ({ @@ -3369,6 +4036,76 @@ describe("events", () => { ]); }); + it("bounds and compacts newest open background-task state rows", () => { + const { db, thread } = setup(); + insertEvents( + db, + noopNotifier, + Array.from({ length: 20 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + scope: turnScope("turn-1"), + type: "item/started" as const, + itemId: `task:${index}`, + itemKind: "backgroundTask" as const, + data: JSON.stringify({ + item: { + type: "backgroundTask", + id: `task:${index}`, + taskType: "local_workflow", + description: `Workflow ${index}`, + status: "pending", + taskStatus: "running", + skipTranscript: false, + summary: "x".repeat(20_000), + }, + }), + })), + ); + + const rows = listLatestOpenBackgroundTaskStateRowsForThread(db, { + limit: 16, + maxBytes: 256_000, + maxDataBytes: 16_000, + threadId: thread.id, + }).rows; + + expect(rows.map((row) => row.sequence)).toEqual( + Array.from({ length: 16 }, (_, index) => index + 5), + ); + expect( + rows.every((row) => Buffer.byteLength(row.data, "utf8") <= 16_000), + ).toBe(true); + expect(JSON.parse(rows[0]?.data ?? "{}")).toMatchObject({ + item: { + id: "task:4", + description: expect.stringContaining("large task details omitted"), + status: "pending", + }, + }); + + const historicalItemIds: string[] = []; + let beforeSequence: number | undefined; + do { + const page = listStoredTimelineWindowEventRowsDescending(db, { + ...(beforeSequence === undefined ? {} : { beforeSequence }), + excludedTypes: [], + limit: 7, + maxBytes: 16_000, + sequenceStart: 1, + threadId: thread.id, + }); + historicalItemIds.push( + ...page.rows.flatMap((row) => + row.itemId === null ? [] : [row.itemId], + ), + ); + beforeSequence = page.rows.at(-1)?.sequence; + if (!page.hasMore) break; + } while (beforeSequence !== undefined); + expect(new Set(historicalItemIds).size).toBe(20); + }); + it("counts active workflow, agent, subagent, and command snapshots by thread", () => { const { db, thread } = setup(); @@ -3769,4 +4506,390 @@ describe("events", () => { ); expect(spy.notifyThread).toHaveBeenCalledTimes(2); }); + + it("keyset-pages direct delegation child turns with a hard result bound", () => { + const { db, thread } = setup(); + const events = Array.from({ length: 120 }, (_, index) => { + const turnId = `child-${String(index).padStart(3, "0")}`; + const sequence = index * 2 + 1; + return [ + { + threadId: thread.id, + sequence, + type: "turn/started" as const, + ...createTurnEventFields({ turnId }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + { + threadId: thread.id, + sequence: sequence + 1, + type: "turn/completed" as const, + ...createTurnEventFields({ turnId }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + ]; + }).flat(); + insertEvents(db, noopNotifier, [ + ...events, + { + threadId: thread.id, + sequence: 241, + type: "system/error", + ...createTurnEventFields({ turnId: "parent-turn" }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + ]); + + const seenTurnIds: string[] = []; + let beforeSequence: number | undefined; + do { + const page = listStoredDelegationChildTurnRanges(db, { + beforeSequence, + directTurnSourceSeqEnd: 1_000, + directTurnSourceSeqStart: 0, + limit: 17, + ownerTurnId: "parent-turn", + parentToolCallId: "delegation-root", + sequenceEnd: 1_000, + sequenceStart: 0, + threadId: thread.id, + }); + expect(page.length).toBeLessThanOrEqual(17); + seenTurnIds.push(...page.map((row) => row.turnId)); + beforeSequence = page.at(-1)?.sourceSeqStart; + if (page.length < 17) break; + } while (beforeSequence !== undefined); + + expect(new Set(seenTurnIds).size).toBe(120); + expect(seenTurnIds).toHaveLength(120); + expect(seenTurnIds).not.toContain("parent-turn"); + expect(seenTurnIds[0]).toBe("child-119"); + expect(seenTurnIds.at(-1)).toBe("child-000"); + }); + + it("filters direct delegation children after grouping the complete snapshot", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "turn/started", + ...createTurnEventFields({ turnId: "early-child" }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + { + threadId: thread.id, + sequence: 40, + type: "turn/started", + ...createTurnEventFields({ turnId: "interval-child" }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + { + threadId: thread.id, + sequence: 50, + type: "turn/completed", + ...createTurnEventFields({ turnId: "early-child" }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + ]); + + expect( + listStoredDelegationChildTurnRanges(db, { + directTurnSourceSeqEnd: 50, + directTurnSourceSeqStart: 40, + limit: 10, + ownerTurnId: "parent-turn", + parentToolCallId: "delegation-root", + sequenceEnd: 50, + sequenceStart: 1, + threadId: thread.id, + }).map((range) => range.turnId), + ).toEqual(["interval-child"]); + }); + + it("maps many candidate gaps through one bounded grouped delegation scan", () => { + const { db, thread } = setup(); + const buckets = Array.from({ length: 60 }, (_, bucketIndex) => ({ + directTurnSourceSeqEnd: bucketIndex * 100 + 100, + directTurnSourceSeqStart: bucketIndex * 100 + 1, + })); + const expectedBucketIndexes = buckets.flatMap((_, bucketIndex) => + bucketIndex % 2 === 0 ? [bucketIndex] : [], + ); + const childEvents = expectedBucketIndexes.flatMap((bucketIndex) => { + const bucketStart = bucketIndex * 100 + 1; + const starts = Array.from({ length: 30 }, (_, childIndex) => ({ + threadId: thread.id, + sequence: bucketStart + childIndex, + type: "turn/started" as const, + ...createTurnEventFields({ + turnId: `bucket-${bucketIndex}-child-${childIndex}`, + }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + })); + return [ + ...starts, + { + threadId: thread.id, + sequence: bucketStart + 150, + type: "turn/completed" as const, + ...createTurnEventFields({ + turnId: `bucket-${bucketIndex}-child-0`, + }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + ]; + }); + insertEvents( + db, + noopNotifier, + childEvents.sort((left, right) => left.sequence - right.sequence), + ); + + const allSpy = vi.spyOn(db, "all"); + const nonemptyBucketIndexes = + listStoredNonemptyDelegationChildTurnBucketIndexes(db, { + buckets, + ownerTurnId: "parent-turn", + parentToolCallId: "delegation-root", + sequenceEnd: 6_000, + sequenceStart: 1, + threadId: thread.id, + }); + expect(allSpy).toHaveBeenCalledTimes(1); + allSpy.mockRestore(); + expect(nonemptyBucketIndexes).toEqual(expectedBucketIndexes); + expect(nonemptyBucketIndexes.length).toBeLessThanOrEqual(buckets.length); + }); + + it("returns scalar recursive extents for only requested delegation roots", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "item/started", + scope: turnScope("child-a"), + itemId: "nested-a", + itemKind: "toolCall", + data: JSON.stringify({ parentToolCallId: "root-a" }), + }, + { + threadId: thread.id, + sequence: 2, + type: "item/started", + scope: turnScope("child-a"), + itemId: "root-a", + itemKind: "toolCall", + data: JSON.stringify({ parentToolCallId: "root-a" }), + }, + { + threadId: thread.id, + sequence: 3, + type: "system/error", + ...createTurnEventFields({ turnId: "grandchild-a" }), + data: JSON.stringify({ parentToolCallId: "nested-a" }), + }, + { + threadId: thread.id, + sequence: 4, + type: "item/started", + scope: turnScope("grandchild-a"), + itemId: "nested-b", + itemKind: "toolCall", + data: JSON.stringify({ parentToolCallId: "nested-a" }), + }, + { + threadId: thread.id, + sequence: 5, + type: "system/error", + ...createTurnEventFields({ turnId: "great-grandchild-a" }), + data: JSON.stringify({ parentToolCallId: "nested-b" }), + }, + { + threadId: thread.id, + sequence: 6, + type: "item/started", + scope: turnScope("great-grandchild-a"), + itemId: "root-a", + itemKind: "toolCall", + data: JSON.stringify({ parentToolCallId: "nested-b" }), + }, + { + threadId: thread.id, + sequence: 7, + type: "turn/started", + ...createTurnEventFields({ turnId: "child-b" }), + data: JSON.stringify({ parentToolCallId: "root-b" }), + }, + { + threadId: thread.id, + sequence: 8, + type: "turn/completed", + ...createTurnEventFields({ turnId: "child-b" }), + data: JSON.stringify({ parentToolCallId: "root-b" }), + }, + ]); + + expect( + listStoredDelegationDescendantRanges(db, { + roots: [ + { ownerTurnId: "parent-a", parentToolCallId: "root-a" }, + { ownerTurnId: "parent-b", parentToolCallId: "root-b" }, + ], + sequenceEnd: 8, + sequenceStart: 0, + threadId: thread.id, + }), + ).toEqual([ + { + eventCount: 6, + parentToolCallId: "root-a", + sourceSeqEnd: 6, + sourceSeqStart: 1, + }, + { + eventCount: 2, + parentToolCallId: "root-b", + sourceSeqEnd: 8, + sourceSeqStart: 7, + }, + ]); + + expect( + listStoredDelegationDescendantRanges(db, { + roots: [{ ownerTurnId: "child-a", parentToolCallId: "root-a" }], + sequenceEnd: 8, + sequenceStart: 0, + threadId: thread.id, + }), + ).toEqual([]); + + expect( + listStoredDelegationDescendantRanges(db, { + roots: [{ ownerTurnId: "parent-a", parentToolCallId: "root-a" }], + sequenceEnd: 8, + sequenceStart: 2, + threadId: thread.id, + }), + ).toEqual([ + { + eventCount: 5, + parentToolCallId: "root-a", + sourceSeqEnd: 6, + sourceSeqStart: 2, + }, + ]); + + expect( + listStoredDelegatedTurnDescendantRanges(db, { + roots: [{ parentToolCallId: "root-a", turnId: "child-a" }], + sequenceEnd: 8, + sequenceStart: 0, + threadId: thread.id, + }), + ).toEqual([ + { + completedAt: null, + createdAt: expect.any(Number), + eventCount: 6, + parentToolCallId: "root-a", + sourceSeqEnd: 6, + sourceSeqStart: 1, + startedAt: expect.any(Number), + turnId: "child-a", + }, + ]); + + expect( + listStoredDelegatedTurnDescendantRanges(db, { + roots: [{ parentToolCallId: "root-a", turnId: "child-a" }], + sequenceEnd: 8, + sequenceStart: 2, + threadId: thread.id, + }), + ).toEqual([ + { + completedAt: null, + createdAt: expect.any(Number), + eventCount: 5, + parentToolCallId: "root-a", + sourceSeqEnd: 6, + sourceSeqStart: 2, + startedAt: expect.any(Number), + turnId: "child-a", + }, + ]); + }); + + it("extends a visible turn through unparented child lifecycle rows", () => { + const { db, thread } = setup(); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "turn/started", + ...createTurnEventFields({ turnId: "parent-turn" }), + data: "{}", + }, + { + threadId: thread.id, + sequence: 2, + type: "item/started", + scope: turnScope("parent-turn"), + itemId: "delegation-root", + itemKind: "toolCall", + data: "{}", + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/completed", + ...createTurnEventFields({ turnId: "parent-turn" }), + data: "{}", + }, + { + threadId: thread.id, + sequence: 4, + type: "turn/started", + ...createTurnEventFields({ turnId: "child-turn" }), + data: "{}", + }, + { + threadId: thread.id, + sequence: 5, + type: "system/error", + ...createTurnEventFields({ turnId: "child-turn" }), + data: JSON.stringify({ parentToolCallId: "delegation-root" }), + }, + { + threadId: thread.id, + sequence: 6, + type: "turn/completed", + ...createTurnEventFields({ turnId: "child-turn" }), + data: "{}", + }, + ]); + + expect( + listStoredTurnDescendantRanges(db, { + roots: [ + { + sourceSeqEnd: 3, + sourceSeqStart: 1, + turnId: "parent-turn", + }, + ], + sequenceEnd: 6, + threadId: thread.id, + }), + ).toEqual([ + { + descendantSourceSeqEnd: 6, + sourceSeqEnd: 3, + sourceSeqStart: 1, + turnId: "parent-turn", + }, + ]); + }); }); diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 02bbea06b3..0de1e45f1c 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -198,9 +198,8 @@ export interface ThreadStoragePathsArgs extends ThreadStoragePathsQuery { threadId: string; } -export interface ThreadTimelineTurnSummaryDetailsArgs extends TimelineTurnSummaryDetailsQuery { - threadId: string; -} +export type ThreadTimelineTurnSummaryDetailsArgs = + TimelineTurnSummaryDetailsQuery & { threadId: string }; export interface ThreadTabsUpdateArgs extends UpdateThreadTabsRequest { threadId: string; @@ -901,14 +900,43 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ); }, async timelineTurnSummaryDetails(input) { + const cursorQuery = { + ...(input.beforeAnchorSeq !== undefined + ? { beforeAnchorSeq: input.beforeAnchorSeq } + : {}), + ...(input.beforeAnchorId !== undefined + ? { beforeAnchorId: input.beforeAnchorId } + : {}), + }; + const selectionQuery = { + turnId: input.turnId, + sourceSeqStart: input.sourceSeqStart, + sourceSeqEnd: input.sourceSeqEnd, + }; return transport.readJson( transport.api.v1.threads[":id"].timeline["turn-summary-details"].$get({ param: { id: input.threadId }, - query: { - turnId: input.turnId, - sourceSeqStart: input.sourceSeqStart, - sourceSeqEnd: input.sourceSeqEnd, - }, + query: + input.detailKind === "delegation-children" + ? { + detailKind: input.detailKind, + ...selectionQuery, + directTurnSourceSeqEnd: input.directTurnSourceSeqEnd, + directTurnSourceSeqStart: input.directTurnSourceSeqStart, + parentToolCallId: input.parentToolCallId, + ...cursorQuery, + } + : { + detailKind: input.detailKind, + ...selectionQuery, + ...(input.contextItemIds !== undefined + ? { contextItemIds: input.contextItemIds } + : {}), + ...(input.parentToolCallId !== undefined + ? { parentToolCallId: input.parentToolCallId } + : {}), + ...cursorQuery, + }, }), ); }, diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index a5dce5a78d..a4cdaff358 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -103,6 +103,76 @@ function createFetchQueue( } describe("@bb/sdk", () => { + it("serializes paired turn-detail pagination cursor fields", async () => { + const body = { + rows: [], + timelinePage: { hasOlderRows: false, olderCursor: null }, + }; + const queue = createFetchQueue([{ body }]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await expect( + sdk.threads.timelineTurnSummaryDetails({ + beforeAnchorId: "timeline-event-window:evt_123", + beforeAnchorSeq: "40", + contextItemIds: '["pending-command"]', + detailKind: "turn", + sourceSeqEnd: "100", + sourceSeqStart: "2", + threadId: "thr_test", + turnId: "turn_test", + }), + ).resolves.toEqual(body); + expect(queue.requests).toEqual([ + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/threads/thr_test/timeline/turn-summary-details?detailKind=turn&turnId=turn_test&sourceSeqStart=2&sourceSeqEnd=100&contextItemIds=%5B%22pending-command%22%5D&beforeAnchorSeq=40&beforeAnchorId=timeline-event-window%3Aevt_123", + }, + ]); + }); + + it("serializes required delegation child interval bounds", async () => { + const body = { + rows: [], + timelinePage: { hasOlderRows: false, olderCursor: null }, + }; + const queue = createFetchQueue([{ body }]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await expect( + sdk.threads.timelineTurnSummaryDetails({ + detailKind: "delegation-children", + directTurnSourceSeqEnd: "80", + directTurnSourceSeqStart: "40", + parentToolCallId: "delegation-1", + sourceSeqEnd: "100", + sourceSeqStart: "2", + threadId: "thr_test", + turnId: "turn_test", + }), + ).resolves.toEqual(body); + expect(queue.requests).toEqual([ + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/threads/thr_test/timeline/turn-summary-details?detailKind=delegation-children&turnId=turn_test&sourceSeqStart=2&sourceSeqEnd=100&directTurnSourceSeqEnd=80&directTurnSourceSeqStart=40&parentToolCallId=delegation-1", + }, + ]); + }); + it("keeps realtime subscriptions distinct under subscribe", () => { const queue = createFetchQueue([]); const sdk = createBbSdk({ diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 59164d8cdc..a72fa329e6 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -523,6 +523,17 @@ export type TimelinePaginationCursor = z.infer< typeof timelinePaginationCursorSchema >; +export const TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_MAX_COUNT = 256; +export const TIMELINE_TURN_DETAILS_CONTEXT_ITEM_ID_MAX_LENGTH = 1_024; +export const TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_QUERY_MAX_LENGTH = 65_536; + +export const timelineTurnDetailsContextItemIdsSchema = z + .array( + z.string().min(1).max(TIMELINE_TURN_DETAILS_CONTEXT_ITEM_ID_MAX_LENGTH), + ) + .max(TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_MAX_COUNT) + .transform((ids) => [...new Set(ids)].sort()); + export const timelinePageMetadataSchema = z .object({ kind: z.enum(["latest", "older"]), @@ -573,11 +584,57 @@ export const threadTimelineQuerySchema = z }); export type ThreadTimelineQuery = z.infer; -export const timelineTurnSummaryDetailsQuerySchema = z.object({ - turnId: z.string().min(1), - sourceSeqStart: z.string().regex(/^\d+$/), - sourceSeqEnd: z.string().regex(/^\d+$/), -}); +const timelineTurnSummaryDetailsCursorQueryFields = { + beforeAnchorSeq: z + .string() + .regex(/^[1-9]\d*$/) + .optional(), + beforeAnchorId: z.string().min(1).optional(), +}; + +export const timelineTurnSummaryDetailsQuerySchema = z + .discriminatedUnion("detailKind", [ + z + .object({ + detailKind: z.literal("turn"), + turnId: z.string().min(1), + sourceSeqStart: z.string().regex(/^\d+$/), + sourceSeqEnd: z.string().regex(/^\d+$/), + contextItemIds: z + .string() + .max(TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_QUERY_MAX_LENGTH) + .optional(), + parentToolCallId: z.string().min(1).optional(), + ...timelineTurnSummaryDetailsCursorQueryFields, + }) + .strict(), + z + .object({ + detailKind: z.literal("delegation-children"), + turnId: z.string().min(1), + sourceSeqStart: z.string().regex(/^\d+$/), + sourceSeqEnd: z.string().regex(/^\d+$/), + parentToolCallId: z.string().min(1), + directTurnSourceSeqStart: z.string().regex(/^\d+$/), + directTurnSourceSeqEnd: z.string().regex(/^\d+$/), + ...timelineTurnSummaryDetailsCursorQueryFields, + }) + .strict(), + ]) + .superRefine((query, context) => { + const hasBeforeAnchorSeq = query.beforeAnchorSeq !== undefined; + const hasBeforeAnchorId = query.beforeAnchorId !== undefined; + + if (hasBeforeAnchorSeq === hasBeforeAnchorId) { + return; + } + + context.addIssue({ + code: "custom", + message: "beforeAnchorSeq and beforeAnchorId must be provided together", + path: hasBeforeAnchorSeq ? ["beforeAnchorId"] : ["beforeAnchorSeq"], + }); + }); export type TimelineTurnSummaryDetailsQuery = z.infer< typeof timelineTurnSummaryDetailsQuerySchema >; @@ -636,17 +693,45 @@ export const threadFilesRawQuerySchema = z.object({ }); export type ThreadFilesRawQuery = z.infer; -export const timelineTurnSummaryDetailsRequestSchema = z.object({ +const timelineTurnSummaryDetailsRequestBase = { turnId: z.string().min(1), sourceSeqStart: z.number().int().nonnegative(), sourceSeqEnd: z.number().int().nonnegative(), -}); + beforeCursor: timelinePaginationCursorSchema.nullable(), +}; + +export const timelineTurnSummaryDetailsRequestSchema = z.discriminatedUnion( + "detailKind", + [ + z + .object({ + detailKind: z.literal("turn"), + ...timelineTurnSummaryDetailsRequestBase, + contextItemIds: timelineTurnDetailsContextItemIdsSchema, + parentToolCallId: z.string().min(1).nullable(), + }) + .strict(), + z + .object({ + detailKind: z.literal("delegation-children"), + ...timelineTurnSummaryDetailsRequestBase, + parentToolCallId: z.string().min(1), + directTurnSourceSeqStart: z.number().int().nonnegative(), + directTurnSourceSeqEnd: z.number().int().nonnegative(), + }) + .strict(), + ], +); export type TimelineTurnSummaryDetailsRequest = z.infer< typeof timelineTurnSummaryDetailsRequestSchema >; export const timelineTurnSummaryDetailsResponseSchema = z.object({ rows: z.array(timelineRowSchema), + timelinePage: z.object({ + hasOlderRows: z.boolean(), + olderCursor: timelinePaginationCursorSchema.nullable(), + }), }); export type TimelineTurnSummaryDetailsResponse = z.infer< typeof timelineTurnSummaryDetailsResponseSchema diff --git a/packages/server-contract/src/thread-timeline.ts b/packages/server-contract/src/thread-timeline.ts index c844d84e0c..053e877368 100644 --- a/packages/server-contract/src/thread-timeline.ts +++ b/packages/server-contract/src/thread-timeline.ts @@ -403,6 +403,36 @@ export type TimelineQuestionWorkRow = z.infer< typeof timelineQuestionWorkRowSchema >; +export interface TimelineDelegationChildInterval { + beforeChildRowId: string | null; + directTurnSourceSeqEnd: number; + directTurnSourceSeqStart: number; +} + +export const timelineDelegationChildIntervalSchema: z.ZodType = + z.object({ + beforeChildRowId: z.string().min(1).nullable(), + directTurnSourceSeqEnd: z.number().int().nonnegative(), + directTurnSourceSeqStart: z.number().int().nonnegative(), + }); + +export interface TimelineDelegationChildPage { + intervals: TimelineDelegationChildInterval[]; + ownerTurnId: string; + parentToolCallId: string; + sourceSeqEnd: number; + sourceSeqStart: number; +} + +export const timelineDelegationChildPageSchema: z.ZodType = + z.object({ + intervals: z.array(timelineDelegationChildIntervalSchema), + ownerTurnId: z.string().min(1), + parentToolCallId: z.string().min(1), + sourceSeqEnd: z.number().int().nonnegative(), + sourceSeqStart: z.number().int().nonnegative(), + }); + export interface TimelineDelegationWorkRow extends TimelineWorkRowBase { workKind: "delegation"; callId: string; @@ -412,6 +442,7 @@ export interface TimelineDelegationWorkRow extends TimelineWorkRowBase { output: string; completedAt: number | null; childRows: TimelineRow[]; + childPage: TimelineDelegationChildPage | null; } export const timelineDelegationWorkRowSchema: z.ZodType = @@ -424,6 +455,7 @@ export const timelineDelegationWorkRowSchema: z.ZodType timelineRowSchema)), + childPage: timelineDelegationChildPageSchema.nullable(), }); /** @@ -480,6 +512,10 @@ export const timelineWorkRowSchema: z.ZodType = z.union([ export interface TimelineTurnRow extends TimelineRowBase { kind: "turn"; turnId: string; + /** Item lifecycles restored only as projection context in lazy details. */ + detailContextItemIds: string[]; + /** Direct delegation owner for child-turn details; null for ordinary turns. */ + detailParentToolCallId: string | null; status: TimelineRowStatus; summaryCount: number; completedAt: number | null; @@ -490,6 +526,8 @@ export const timelineTurnRowSchema: z.ZodType = z.lazy(() => timelineRowBaseSchema.extend({ kind: z.literal("turn"), turnId: z.string().min(1), + detailContextItemIds: z.array(z.string().min(1)), + detailParentToolCallId: z.string().min(1).nullable(), status: timelineRowStatusSchema, summaryCount: z.number().int().nonnegative(), completedAt: z.number().nullable(), diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index a4d6cfd9df..4bc4ee0990 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -227,6 +227,16 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadTimelineQuerySchema.afterSequence", ], }, + { + reason: + "Turn-summary detail queries omit both cursor fields on the newest page; ordinary turn summaries omit context item ids and parent delegation provenance when they have no separately visible lifecycle or parent delegation.", + fields: [ + "timelineTurnSummaryDetailsQuerySchema.beforeAnchorId", + "timelineTurnSummaryDetailsQuerySchema.beforeAnchorSeq", + "timelineTurnSummaryDetailsQuerySchema.contextItemIds", + "timelineTurnSummaryDetailsQuerySchema.parentToolCallId", + ], + }, { reason: "Timeline responses omit context-window usage when the provider did not report it.", @@ -1019,10 +1029,23 @@ describe("server-contract canonical schemas", () => { ).toThrow("Project path must be an absolute path."); expect( - timelineTurnSummaryDetailsResponseSchema.parse({ rows: [] }), + timelineTurnSummaryDetailsResponseSchema.parse({ + rows: [], + timelinePage: { hasOlderRows: false, olderCursor: null }, + }), ).toEqual({ rows: [], + timelinePage: { hasOlderRows: false, olderCursor: null }, }); + expect(() => + contract.timelineTurnSummaryDetailsQuerySchema.parse({ + beforeAnchorSeq: "10", + detailKind: "turn", + sourceSeqEnd: "20", + sourceSeqStart: "1", + turnId: "turn-1", + }), + ).toThrow("beforeAnchorId"); }); it("keeps only intentional optional request fields", () => { @@ -1415,6 +1438,7 @@ describe("server-contract clients", () => { publicClient.threads[":id"].timeline["turn-summary-details"].$url({ param: { id: "thr_123" }, query: { + detailKind: "turn", turnId: "turn_123", sourceSeqStart: "1", sourceSeqEnd: "2", @@ -1546,6 +1570,137 @@ describe("server-contract clients", () => { ).toThrow(); }); + it("bounds and canonicalizes turn-detail context item ids", () => { + const request = { + beforeCursor: null, + contextItemIds: Array.from( + { length: contract.TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_MAX_COUNT }, + (_, index) => `item-${index}`, + ), + detailKind: "turn" as const, + parentToolCallId: null, + sourceSeqEnd: 2, + sourceSeqStart: 1, + turnId: "turn-1", + }; + const maxLengthId = "x".repeat( + contract.TIMELINE_TURN_DETAILS_CONTEXT_ITEM_ID_MAX_LENGTH, + ); + + const parsedRequest = + contract.timelineTurnSummaryDetailsRequestSchema.parse(request); + if (parsedRequest.detailKind !== "turn") { + throw new Error("Expected turn detail request"); + } + expect(parsedRequest.contextItemIds).toHaveLength( + contract.TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_MAX_COUNT, + ); + expect( + contract.timelineTurnDetailsContextItemIdsSchema.parse([maxLengthId]), + ).toEqual([maxLengthId]); + expect(() => + contract.timelineTurnSummaryDetailsRequestSchema.parse({ + ...request, + contextItemIds: [ + ...request.contextItemIds, + `item-${request.contextItemIds.length}`, + ], + }), + ).toThrow(); + expect(() => + contract.timelineTurnDetailsContextItemIdsSchema.parse([ + `${maxLengthId}x`, + ]), + ).toThrow(); + + expect( + contract.timelineTurnDetailsContextItemIdsSchema.parse(["b", "a", "a"]), + ).toEqual(["a", "b"]); + expect( + contract.timelineTurnDetailsContextItemIdsSchema.parse([ + " id ", + "é", + "e\u0301", + ]), + ).toEqual([" id ", "e\u0301", "é"]); + + const rawQuery = "x".repeat( + contract.TIMELINE_TURN_DETAILS_CONTEXT_ITEM_IDS_QUERY_MAX_LENGTH, + ); + const query = { + contextItemIds: rawQuery, + detailKind: "turn" as const, + sourceSeqEnd: "2", + sourceSeqStart: "1", + turnId: "turn-1", + }; + const parsedQuery = + contract.timelineTurnSummaryDetailsQuerySchema.parse(query); + if (parsedQuery.detailKind !== "turn") { + throw new Error("Expected turn detail query"); + } + expect(parsedQuery.contextItemIds).toBe(rawQuery); + expect(() => + contract.timelineTurnSummaryDetailsQuerySchema.parse({ + ...query, + contextItemIds: `${rawQuery}x`, + }), + ).toThrow(); + + const delegationRequest = { + beforeCursor: null, + detailKind: "delegation-children" as const, + directTurnSourceSeqEnd: 20, + directTurnSourceSeqStart: 10, + parentToolCallId: "delegation-1", + sourceSeqEnd: 30, + sourceSeqStart: 1, + turnId: "turn-1", + }; + expect( + contract.timelineTurnSummaryDetailsRequestSchema.parse(delegationRequest), + ).toEqual(delegationRequest); + expect(() => + contract.timelineTurnSummaryDetailsRequestSchema.parse({ + ...delegationRequest, + directTurnSourceSeqStart: undefined, + }), + ).toThrow(); + expect(() => + contract.timelineTurnSummaryDetailsRequestSchema.parse({ + ...request, + directTurnSourceSeqEnd: 20, + directTurnSourceSeqStart: 10, + }), + ).toThrow(); + + const delegationQuery = { + detailKind: "delegation-children" as const, + directTurnSourceSeqEnd: "20", + directTurnSourceSeqStart: "10", + parentToolCallId: "delegation-1", + sourceSeqEnd: "30", + sourceSeqStart: "1", + turnId: "turn-1", + }; + expect( + contract.timelineTurnSummaryDetailsQuerySchema.parse(delegationQuery), + ).toEqual(delegationQuery); + expect(() => + contract.timelineTurnSummaryDetailsQuerySchema.parse({ + ...delegationQuery, + directTurnSourceSeqEnd: undefined, + }), + ).toThrow(); + expect(() => + contract.timelineTurnSummaryDetailsQuerySchema.parse({ + ...query, + directTurnSourceSeqEnd: "20", + directTurnSourceSeqStart: "10", + }), + ).toThrow(); + }); + it("requires parent change timeline system rows to carry status", () => { const baseRow = { id: "row-1", diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 73ae0dbf0b..bb3ab57d8d 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -134,6 +134,7 @@ export interface BuildThreadTimelineTurnDetailsFromEventsOptions extends ThreadT threadStatus: Thread["status"]; /** See {@link ThreadTimelineFromEventsBaseOptions.threadName}. */ threadName: string; + turnId: string; /** See {@link ThreadTimelineFromEventsBaseOptions.workspaceRoot}. */ workspaceRoot: string | null; } @@ -689,6 +690,7 @@ function convertMessage( description: message.description ?? null, output: message.output, completedAt: message.completedAt, + childPage: null, childRows: filterDelegationChildRows( buildTimelineRows(message.childProjection, { includeNestedRows: true, @@ -982,6 +984,8 @@ function buildTurnSummaryRow({ id: rowId, threadId: turn.threadId, turnId: turn.turnId, + detailContextItemIds: [], + detailParentToolCallId: null, sourceSeqStart: bounds.sourceSeqStart, sourceSeqEnd: bounds.sourceSeqEnd, startedAt, @@ -1289,6 +1293,25 @@ export function buildThreadTimelineTurnDetailsFromEvents( }; } + // Bounded raw pages can backfill the turn root while omitting lifecycle + // events that normally define the synthetic summary's exact outer range. + // The server has already validated and filtered the request to one turn, so + // use that turn's overlapping summary as the page container when its exact + // range is wider than this raw window. + const requestedTurnSummaries = nestedRows.filter( + (row): row is TimelineTurnSummaryRow => + row.kind === "turn" && + row.turnId === args.options.turnId && + row.sourceSeqEnd >= args.options.sourceSeqStart && + row.sourceSeqStart <= args.options.sourceSeqEnd, + ); + if (requestedTurnSummaries.length === 1) { + return { + kind: "matched", + rows: requestedTurnSummaries[0]?.children ?? [], + }; + } + if (hasTurnSummaryRows(nestedRows)) { return { kind: "missing-match", diff --git a/packages/thread-view/src/timeline-view.ts b/packages/thread-view/src/timeline-view.ts index f9bc2d5b55..cb7e397558 100644 --- a/packages/thread-view/src/timeline-view.ts +++ b/packages/thread-view/src/timeline-view.ts @@ -900,9 +900,78 @@ function toTimelineViewWorkRow( // delegations stay open so the live frontier keeps showing as bundles + // leaves. const closedScope = row.status !== "pending"; + const anchoredIntervals = + row.childPage?.intervals.filter( + (interval): interval is typeof interval & { beforeChildRowId: string } => + interval.beforeChildRowId !== null, + ) ?? []; + if (anchoredIntervals.length === 0) { + return { + ...row, + childRows: buildTimelineViewRows(row.childRows, { cache, closedScope }), + }; + } + + const childRowIndexById = new Map( + row.childRows.map((childRow, index) => [childRow.id, index]), + ); + const anchorIdsByChildRowIndex = new Map(); + for (const interval of anchoredIntervals) { + const childRowIndex = childRowIndexById.get(interval.beforeChildRowId); + if (childRowIndex === undefined) continue; + const anchorIds = anchorIdsByChildRowIndex.get(childRowIndex) ?? []; + anchorIds.push(interval.beforeChildRowId); + anchorIdsByChildRowIndex.set(childRowIndex, anchorIds); + } + const splitIndexes = [ + 0, + ...anchorIdsByChildRowIndex.keys(), + row.childRows.length, + ] + .filter( + (value, index, values) => + value >= 0 && + value <= row.childRows.length && + values.indexOf(value) === index, + ) + .sort((left, right) => left - right); + const childRows: ThreadTimelineViewRow[] = []; + const projectedAnchorIdByRawAnchorId = new Map(); + for (const [index, splitIndex] of splitIndexes.entries()) { + const nextSplitIndex = splitIndexes[index + 1]; + if (nextSplitIndex === undefined || splitIndex === nextSplitIndex) continue; + const projectedChunk = buildTimelineViewRows( + row.childRows.slice(splitIndex, nextSplitIndex), + { cache, closedScope }, + ); + const projectedAnchorRow = projectedChunk[0]; + if (projectedAnchorRow) { + for (const rawAnchorId of anchorIdsByChildRowIndex.get(splitIndex) ?? + []) { + projectedAnchorIdByRawAnchorId.set(rawAnchorId, projectedAnchorRow.id); + } + } + childRows.push(...projectedChunk); + } + return { ...row, - childRows: buildTimelineViewRows(row.childRows, { cache, closedScope }), + childPage: + row.childPage === null + ? null + : { + ...row.childPage, + intervals: row.childPage.intervals.map((interval) => ({ + ...interval, + beforeChildRowId: + interval.beforeChildRowId === null + ? null + : (projectedAnchorIdByRawAnchorId.get( + interval.beforeChildRowId, + ) ?? interval.beforeChildRowId), + })), + }, + childRows, }; } diff --git a/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts b/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts index 956a59711d..665c90d9bd 100644 --- a/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts +++ b/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts @@ -462,6 +462,8 @@ describe("timeline CLI rendering snapshots", () => { startedAt: 1, createdAt: 1, kind: "turn", + detailContextItemIds: [], + detailParentToolCallId: null, status: "completed", summaryCount: 0, completedAt: null, @@ -507,6 +509,7 @@ describe("timeline CLI rendering snapshots", () => { expect(timeline.turnRows).toHaveLength(1); expect(timeline.turnRows[0]).toMatchObject({ kind: "turn", + detailContextItemIds: [], status: "completed", }); expect( @@ -517,10 +520,7 @@ describe("timeline CLI rendering snapshots", () => { const pendingSteerRow = timeline.rows.find( ( row, - ): row is Extract< - TimelineRow, - { kind: "conversation"; role: "user" } - > => + ): row is Extract => row.kind === "conversation" && row.role === "user" && row.turnRequest.status === "pending", @@ -1012,9 +1012,9 @@ describe("timeline CLI rendering snapshots", () => { // Delegation children render flat — no synthetic turn wrapper. Each // child row carries the delegation's scoped id prefix so it does not // collide with rows from the root turn. - expect( - delegation?.childRows.some((row) => row.kind === "turn"), - ).toBe(false); + expect(delegation?.childRows.some((row) => row.kind === "turn")).toBe( + false, + ); expect(delegation?.childRows.length ?? 0).toBeGreaterThan(0); for (const childRow of delegation?.childRows ?? []) { expect(childRow.id.startsWith(`${delegation?.id}:child:`)).toBe(true); @@ -1191,9 +1191,9 @@ describe("timeline CLI rendering snapshots", () => { }), ]), ); - expect( - delegation?.childRows.some((row) => row.turnId === "turn-2"), - ).toBe(false); + expect(delegation?.childRows.some((row) => row.turnId === "turn-2")).toBe( + false, + ); expect(rootFollowUp).toMatchObject({ kind: "conversation", role: "assistant", @@ -1777,9 +1777,9 @@ describe("timeline CLI rendering snapshots", () => { }), ]), ); - expect( - delegation?.childRows.some((row) => row.turnId === "turn-2"), - ).toBe(false); + expect(delegation?.childRows.some((row) => row.turnId === "turn-2")).toBe( + false, + ); expect(rootFollowUp).toMatchObject({ kind: "conversation", role: "assistant", @@ -1841,9 +1841,9 @@ describe("timeline CLI rendering snapshots", () => { expect(delegation).toBeDefined(); expect(delegation?.status).toBe("pending"); - expect( - delegation?.childRows.some((row) => row.kind === "turn"), - ).toBe(false); + expect(delegation?.childRows.some((row) => row.kind === "turn")).toBe( + false, + ); expect(delegation?.childRows.length ?? 0).toBeGreaterThanOrEqual(3); // A regression that re-introduces a synthetic turn wrapper would // produce a "Worked for X" or "Working for X" label inside the diff --git a/packages/thread-view/test/timeline-row-title.test.ts b/packages/thread-view/test/timeline-row-title.test.ts index 40567eda31..f425a01b1d 100644 --- a/packages/thread-view/test/timeline-row-title.test.ts +++ b/packages/thread-view/test/timeline-row-title.test.ts @@ -273,6 +273,7 @@ function delegationRow(): TimelineViewDelegationWorkRow { description: "Review correctness + plan adherence", output: "", completedAt: 45_001, + childPage: null, childRows: [], }; } @@ -380,6 +381,8 @@ function turnRow(): TimelineViewTurnRow { return { ...baseRow("turn-1"), kind: "turn", + detailContextItemIds: [], + detailParentToolCallId: null, turnId: "turn-1", status: "completed", summaryCount: 1, diff --git a/packages/thread-view/test/timeline-view.test.ts b/packages/thread-view/test/timeline-view.test.ts index c80229dba0..1bfa81863c 100644 --- a/packages/thread-view/test/timeline-view.test.ts +++ b/packages/thread-view/test/timeline-view.test.ts @@ -28,7 +28,10 @@ interface WorkRowOverrides { turnId?: string | null; } -function baseRow(id: string, overrides: WorkRowOverrides = {}): TimelineRowBase { +function baseRow( + id: string, + overrides: WorkRowOverrides = {}, +): TimelineRowBase { return { id, threadId: "thread-1", @@ -84,9 +87,8 @@ function commandRow({ source: null, output: "", exitCode: 0, - completedAt: durationMs === null - ? null - : (baseOverrides.startedAt ?? 1) + durationMs, + completedAt: + durationMs === null ? null : (baseOverrides.startedAt ?? 1) + durationMs, approvalStatus: null, activityIntents, }; @@ -131,7 +133,9 @@ function commandRowReadingPaths(paths: readonly string[], seq: number) { }); } -function explorationIntents(row: ThreadTimelineViewRow): TimelineActivityIntent[] { +function explorationIntents( + row: ThreadTimelineViewRow, +): TimelineActivityIntent[] { if (row.kind !== "work") return []; if (row.workKind !== "command" && row.workKind !== "tool") return []; return [...row.activityIntents]; @@ -200,9 +204,8 @@ function toolRow({ toolName, toolArgs, output, - completedAt: durationMs === null - ? null - : (baseOverrides.startedAt ?? 1) + durationMs, + completedAt: + durationMs === null ? null : (baseOverrides.startedAt ?? 1) + durationMs, approvalStatus: null, activityIntents, }; @@ -231,6 +234,7 @@ function delegationRow({ description: "Review timeline grouping", output: "", completedAt: (baseOverrides.startedAt ?? 1) + 500, + childPage: null, childRows, }; } @@ -364,9 +368,7 @@ describe("buildTimelineViewRows", () => { workKind: "command", id: "command-1", }); - expect(nextSummary.id).toBe( - "thread-1:turn-1:work-summary:command-1", - ); + expect(nextSummary.id).toBe("thread-1:turn-1:work-summary:command-1"); expect(nextSummary.status).toBe("completed"); expect(nextSummary.sourceSeqStart).toBe(1); expect(nextSummary.sourceSeqEnd).toBe(2); @@ -374,9 +376,7 @@ describe("buildTimelineViewRows", () => { "command-1", "command-2", ]); - expect(buildTimelineWorkSummaryLabel(nextSummary)).toBe( - "Ran 2 commands", - ); + expect(buildTimelineWorkSummaryLabel(nextSummary)).toBe("Ran 2 commands"); }); it("keeps bundle row identity stable across activity transitions", () => { @@ -412,9 +412,9 @@ describe("buildTimelineViewRows", () => { expect(completedSummary.id).toBe(pendingSummary.id); // Active-latest treatment is decided by list-level renderers, not by the // grouper. The label generator opts in to active wording only when asked. - expect(buildTimelineWorkSummaryLabel(pendingSummary, { active: true })).toBe( - "Running 2 commands", - ); + expect( + buildTimelineWorkSummaryLabel(pendingSummary, { active: true }), + ).toBe("Running 2 commands"); expect(buildTimelineWorkSummaryLabel(completedSummary)).toBe( "Ran 2 commands", ); @@ -577,9 +577,9 @@ describe("buildTimelineViewRows", () => { ]); const summary = expectBundleSummaryRow(rows[0]); - expect( - buildTimelineWorkSummaryLabel(summary, { active: true }), - ).toBe("Running 2 tools"); + expect(buildTimelineWorkSummaryLabel(summary, { active: true })).toBe( + "Running 2 tools", + ); }); it("collapses completed delegation children into a step-summary", () => { @@ -611,9 +611,7 @@ describe("buildTimelineViewRows", () => { expect(rows).toHaveLength(1); expect(delegation.childRows).toHaveLength(1); - expect(buildTimelineWorkSummaryLabel(childSummary)).toBe( - "Ran 2 commands", - ); + expect(buildTimelineWorkSummaryLabel(childSummary)).toBe("Ran 2 commands"); expect(childSummary).toMatchObject({ status: "completed", sourceSeqStart: 10, From 95c3c574feee3d43e33c34d3f8c0e3d735b3d55b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 15 Jul 2026 21:36:07 -0700 Subject: [PATCH 2/3] Preserve timeline row identity only when equivalent --- .../useThreadTimelineController.test.tsx | 138 +++++++++++++++++- .../timeline/useThreadTimelineController.ts | 27 +++- 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index bbddb0f92d..729f6ca542 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -10,8 +10,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as api from "@/lib/api"; import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; -import { useThreadTimelineController } from "./useThreadTimelineController"; +import { + commandRow, + conversationRow, + delegationRow, +} from "@/test/fixtures/thread-timeline-rows"; +import { + mergeLatestTimelineRows, + useThreadTimelineController, +} from "./useThreadTimelineController"; vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); @@ -61,6 +68,133 @@ function makeTimelineResponse({ }; } +describe("timeline row identity preservation", () => { + it("replaces a same-identity command when pending state becomes interrupted", () => { + const pendingRow = commandRow({ + command: "pnpm test", + durationMs: null, + id: "active-command", + output: "running", + status: "pending", + }); + const interruptedRow = { + ...pendingRow, + status: "interrupted" as const, + }; + + const merge = mergeLatestTimelineRows({ + latestRows: [interruptedRow], + loadedRows: [pendingRow], + }); + + expect(merge.rows[0]).toBe(interruptedRow); + expect(merge.rows[0]).toMatchObject({ status: "interrupted" }); + }); + + it("replaces a delegation when nested output or lazy interval content changes", () => { + const childRow = commandRow({ + command: "rg timeline", + durationMs: null, + id: "delegated-command", + output: "partial output", + status: "pending", + }); + const delegation = { + ...delegationRow({ + childRows: [childRow], + durationMs: null, + id: "delegation", + status: "pending", + }), + childPage: { + intervals: [ + { + beforeChildRowId: null, + directTurnSourceSeqEnd: 8, + directTurnSourceSeqStart: 2, + }, + ], + ownerTurnId: "turn-1", + parentToolCallId: "delegation", + sourceSeqEnd: 10, + sourceSeqStart: 1, + }, + }; + const nestedOutputUpdate = { + ...delegation, + childRows: [{ ...childRow, output: "complete output" }], + }; + const intervalUpdate = { + ...delegation, + childPage: { + ...delegation.childPage, + intervals: [ + { + ...delegation.childPage.intervals[0], + directTurnSourceSeqEnd: 9, + }, + ], + }, + }; + + expect( + mergeLatestTimelineRows({ + latestRows: [nestedOutputUpdate], + loadedRows: [delegation], + }).rows[0], + ).toBe(nestedOutputUpdate); + expect( + mergeLatestTimelineRows({ + latestRows: [intervalUpdate], + loadedRows: [delegation], + }).rows[0], + ).toBe(intervalUpdate); + }); + + it("retains the previous reference for a fully equivalent cloned row", () => { + const childRow = commandRow({ + command: "rg timeline", + id: "delegated-command", + output: "complete output", + }); + const delegation = { + ...delegationRow({ childRows: [childRow], id: "delegation" }), + childPage: { + intervals: [ + { + beforeChildRowId: null, + directTurnSourceSeqEnd: 8, + directTurnSourceSeqStart: 2, + }, + ], + ownerTurnId: "turn-1", + parentToolCallId: "delegation", + sourceSeqEnd: 10, + sourceSeqStart: 1, + }, + }; + const refetchedDelegation = { + ...delegation, + childRows: delegation.childRows.map((row) => ({ ...row })), + childPage: { + ...delegation.childPage, + intervals: delegation.childPage.intervals.map((interval) => ({ + ...interval, + })), + }, + }; + const loadedRows = [delegation]; + + const merge = mergeLatestTimelineRows({ + latestRows: [refetchedDelegation], + loadedRows, + }); + + expect(merge.rows).toBe(loadedRows); + expect(merge.rows[0]).toBe(delegation); + }); +}); + describe("useThreadTimelineController", () => { it("keeps an initial timeline refetch in loading state instead of showing the previous error", async () => { const response = makeTimelineResponse(); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index bb8da2cce2..8f7f61f9e3 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -6,6 +6,7 @@ import { useRef, useState, } from "react"; +import { replaceEqualDeep } from "@tanstack/react-query"; import type { ThreadTimelineResponse, TimelinePaginationCursor, @@ -74,7 +75,7 @@ interface MergeLatestTimelineRowsResult { interface TimelineRowIdentityEntry { row: TimelineRow; - signature: string; + fastChangeSignature: string; } interface PreserveTimelineRowIdentityArgs { @@ -183,7 +184,7 @@ function appendTimelineRowsPreservingOrder( } } -function timelineRowIdentitySignature(row: TimelineRow): string { +function timelineRowFastChangeSignature(row: TimelineRow): string { return [ row.kind, row.id, @@ -196,6 +197,20 @@ function timelineRowIdentitySignature(row: TimelineRow): string { ].join("\u001f"); } +function areTimelineRowsRenderEquivalent( + previousRow: TimelineRow, + nextRow: TimelineRow, +): boolean { + if (previousRow === nextRow) { + return true; + } + // Timeline rows are parsed JSON-compatible contract values. Structural + // equality checks every enumerable field recursively, including nested rows + // and lazy child-page intervals, without allocating serialized output copies. + // Different unsupported/non-plain values fail closed to the next row. + return replaceEqualDeep(previousRow, nextRow) === previousRow; +} + function buildTimelineRowIdentityMap( rows: readonly TimelineRow[], ): ReadonlyMap { @@ -203,7 +218,7 @@ function buildTimelineRowIdentityMap( for (const row of rows) { rowsById.set(row.id, { row, - signature: timelineRowIdentitySignature(row), + fastChangeSignature: timelineRowFastChangeSignature(row), }); } return rowsById; @@ -216,7 +231,11 @@ function preserveTimelineRowIdentity({ const previousRowsById = buildTimelineRowIdentityMap(previousRows); return nextRows.map((row) => { const previous = previousRowsById.get(row.id); - if (previous && previous.signature === timelineRowIdentitySignature(row)) { + if ( + previous && + previous.fastChangeSignature === timelineRowFastChangeSignature(row) && + areTimelineRowsRenderEquivalent(previous.row, row) + ) { return previous.row; } return row; From c945da4b64bba9f001b8b14654d8e7cb5bc92a96 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 15 Jul 2026 21:48:58 -0700 Subject: [PATCH 3/3] test(integration): complete timeline fake rows --- tests/integration/fake/smoke/timeline-response.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/fake/smoke/timeline-response.test.ts b/tests/integration/fake/smoke/timeline-response.test.ts index aa0a8b7d37..232e524cb0 100644 --- a/tests/integration/fake/smoke/timeline-response.test.ts +++ b/tests/integration/fake/smoke/timeline-response.test.ts @@ -75,6 +75,8 @@ describe("timeline response helpers", () => { sourceSeqEnd: 1, startedAt: 1, createdAt: 1, + detailContextItemIds: [], + detailParentToolCallId: null, status: "completed", summaryCount: 1, completedAt: null, @@ -108,6 +110,7 @@ describe("timeline response helpers", () => { output: "", completedAt: null, childRows: [ASSISTANT_ROW], + childPage: null, }, ]);