From e26ab48062af83456c4497e2c7a63db97fae43c7 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 12:23:33 -0700 Subject: [PATCH 1/4] Paginate completed turn timeline details --- .../thread/timeline/ThreadTimelineRows.tsx | 94 +++---- apps/app/src/hooks/queries/query-keys.ts | 22 ++ .../src/hooks/queries/thread-queries.test.tsx | 61 +++++ apps/app/src/hooks/queries/thread-queries.ts | 30 +++ apps/cli/package.json | 1 + apps/cli/src/commands/thread/show.ts | 6 +- apps/mobile/src/data/thread-detail/index.ts | 2 +- .../thread-detail/thread-detail-queries.ts | 49 ++-- apps/mobile/src/lib/query/query-keys.ts | 19 +- .../thread/timeline/TurnChildrenLoader.tsx | 62 ++++- .../timeline/renderers/turn/TurnRow.tsx | 20 +- .../src/screens/thread/timeline/rows.test.ts | 4 + .../src/screens/thread/timeline/rows.ts | 25 +- apps/server/src/routes/threads/data.ts | 21 ++ .../bb-plugin-authoring/SKILL.md | 2 +- apps/server/src/services/threads/timeline.ts | 251 ++++++++++++++++-- .../test/public/public-thread-data.test.ts | 13 + .../threads/timeline-in-turn-window.test.ts | 90 +++++-- .../src/timeline/timeline-merge.ts | 96 ++++++- .../client-core/test/timeline-merge.test.ts | 84 +++++- packages/db/src/data/events.ts | 74 +++++- packages/db/src/data/index.ts | 2 + packages/sdk/src/areas/threads.ts | 25 ++ packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/api/threads.ts | 16 ++ packages/server-contract/src/public-api.ts | 11 + .../server-contract/test/contract.test.ts | 21 ++ .../thread-view/src/build-thread-timeline.ts | 57 +++- .../src/completed-turn-grouping.ts | 64 +++-- packages/thread-view/src/index.ts | 1 + .../thread-view/src/timeline-noise-events.ts | 5 + .../test/completed-turn-grouping.test.ts | 116 +++++++- pnpm-lock.yaml | 7 +- 33 files changed, 1147 insertions(+), 205 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index f6fbb5b7a2..dda7802471 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -48,6 +48,7 @@ import { collectTimelineAutoExpansionRowIds, isNonExpandableSummary, isRowExpandable, + mergeTimelineTurnDetailPages, } from "@bb/client-core"; import { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core"; import type { @@ -117,8 +118,8 @@ import { } from "./timeline-row-containment.js"; import { NESTED_TIMELINE_GROUP_LINE_CLASS_NAME } from "./timeline-nested-group-line.js"; import { getThreadRoutePath } from "@/lib/route-paths"; -import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries"; -import { type ThreadTimelineTurnSummaryDetailsQueryIdentity } from "@/hooks/queries/query-keys"; +import { useThreadTimelineTurnDetails } from "@/hooks/queries/thread-queries"; +import { type ThreadTimelineTurnDetailsQueryIdentity } from "@/hooks/queries/query-keys"; import { useSenderThreadMetadataById, type SenderThreadMetadata, @@ -382,14 +383,6 @@ interface TimelineRowTitleRenderStateCache { state: TimelineRowTitleRenderState; } -interface BuildTurnSummaryDetailsIdentityArgs { - rowSourceSeqEnd: TimelineViewTurnRow["sourceSeqEnd"]; - rowSourceSeqStart: TimelineViewTurnRow["sourceSeqStart"]; - rowThreadId: TimelineViewTurnRow["threadId"]; - rowTurnId: TimelineViewTurnRow["turnId"]; - threadId: string | undefined; -} - interface TimelineRowsOwnerKeyArgs { threadId: string | undefined; timelineRows: readonly TimelineRow[]; @@ -650,21 +643,6 @@ function useTimelineSearchExpansionRowIds( }, [inheritedRowIds, location.state, rows, threadId]); } -function buildTurnSummaryDetailsIdentity({ - rowSourceSeqEnd, - rowSourceSeqStart, - rowThreadId, - rowTurnId, - threadId, -}: BuildTurnSummaryDetailsIdentityArgs): ThreadTimelineTurnSummaryDetailsQueryIdentity { - return { - sourceSeqEnd: rowSourceSeqEnd, - sourceSeqStart: rowSourceSeqStart, - threadId: threadId ?? rowThreadId, - turnId: rowTurnId, - }; -} - function timelineRowsOwnerKey({ threadId, timelineRows, @@ -1515,36 +1493,33 @@ function LazyTurnRowBody({ showAssistantMessageActions, }: LazyTurnRowBodyProps) { const { getViewRows, threadId } = useTimelineRendererStaticContext(); - const { - sourceSeqEnd: rowSourceSeqEnd, - sourceSeqStart: rowSourceSeqStart, - threadId: rowThreadId, - turnId: rowTurnId, - } = row; - const identity = useMemo( - () => - buildTurnSummaryDetailsIdentity({ - rowSourceSeqEnd, - rowSourceSeqStart, - rowThreadId, - rowTurnId, - threadId, - }), - [rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId], + const { threadId: rowThreadId, turnId: rowTurnId } = row; + const identity = useMemo( + () => ({ threadId: threadId ?? rowThreadId, turnId: rowTurnId }), + [rowThreadId, rowTurnId, threadId], ); const { data: detail, + fetchNextPage, + hasNextPage, isError, + isFetchingNextPage, refetch, - } = useThreadTimelineTurnSummaryDetails(identity); + } = useThreadTimelineTurnDetails(identity); const handleRetry = useCallback((): void => { void refetch(); }, [refetch]); + const handleLoadMore = useCallback((): void => { + void fetchNextPage(); + }, [fetchNextPage]); 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 }) + getViewRows( + mergeTimelineTurnDetailPages(detail.pages.map((page) => page.rows)), + { closedScope: true }, + ) : null; if (!rows && isError) { @@ -1566,16 +1541,29 @@ function LazyTurnRowBody({ } if (rows) { return ( - +
+ + {hasNextPage ? ( + + ) : null} +
); } return ( diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 3d1dd90129..4fdccb947e 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -354,6 +354,16 @@ export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { threadId: string; turnId: string; } +export interface ThreadTimelineTurnDetailsQueryIdentity { + threadId: string; + turnId: string; +} +type ThreadTimelineTurnDetailsQueryKey = readonly [ + typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, + string, + string, + "pages", +]; type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, @@ -932,6 +942,18 @@ export function threadTimelineTurnSummaryDetailsQueryKey({ ]; } +export function threadTimelineTurnDetailsQueryKey({ + threadId, + turnId, +}: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey { + return [ + THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, + threadId, + turnId, + "pages", + ]; +} + export function threadTimelineQueryKeyPrefix( threadId: string, ): ThreadTimelineQueryKeyPrefix { diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 075fdddf16..d4c3f304b4 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -7,6 +7,7 @@ import type { SidebarBootstrapResponse, ThreadTimelineResponse, ThreadWithIncludesResponse, + TimelineTurnDetailsResponse, } from "@bb/server-contract"; import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; import * as api from "@/lib/api"; @@ -34,6 +35,7 @@ import { useThreadQueuedMessages, useThreadStorageLocation, useThreadTimeline, + useThreadTimelineTurnDetails, } from "./thread-queries"; vi.mock("@/lib/api", async (importOriginal) => { @@ -52,6 +54,7 @@ vi.mock("@/lib/sdk", () => ({ queuedMessages: { list: vi.fn() }, storageLocation: vi.fn(), timeline: vi.fn(), + timelineTurnDetails: vi.fn(), }, }, })); @@ -179,6 +182,64 @@ beforeEach(() => { }); }); +describe("useThreadTimelineTurnDetails", () => { + it("stops after the first page until the caller requests the next one", async () => { + vi.mocked(sdk.threads.timelineTurnDetails).mockImplementation( + async (input) => { + const firstPage = input.cursor === undefined; + return { + nextCursor: firstPage ? "cursor-2" : null, + rows: [ + { + id: firstPage ? "work-1" : "work-2", + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: firstPage ? 1 : 2, + sourceSeqEnd: firstPage ? 1 : 2, + startedAt: firstPage ? 1 : 2, + createdAt: firstPage ? 1 : 2, + kind: "system", + systemKind: "debug", + title: "Work", + detail: null, + status: null, + }, + ], + } satisfies TimelineTurnDetailsResponse; + }, + ); + const { wrapper } = createQueryClientTestHarness(); + const result = renderHook( + () => + useThreadTimelineTurnDetails({ + threadId: "thread-1", + turnId: "turn-1", + }), + { wrapper }, + ); + + await waitFor(() => expect(result.result.current.isSuccess).toBe(true)); + expect(sdk.threads.timelineTurnDetails).toHaveBeenCalledTimes(1); + expect( + result.result.current.data?.pages.flatMap((page) => page.rows), + ).toHaveLength(1); + + await act(async () => { + await result.result.current.fetchNextPage(); + }); + + expect(sdk.threads.timelineTurnDetails).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: "cursor-2" }), + ); + await waitFor(() => + expect( + result.result.current.data?.pages.flatMap((page) => page.rows), + ).toHaveLength(2), + ); + }); +}); + describe("useThreadDetailBootstrap", () => { it("starts the timeline request before the thread bootstrap settles", async () => { let resolveThread: diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index a3c72ceecc..96c8aba038 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -78,8 +78,10 @@ import { threadHostFilePreviewQueryKey, threadConversationOutlineQueryKey, threadTimelineQueryKey, + threadTimelineTurnDetailsQueryKey, threadTimelineTurnSummaryDetailsQueryKey, threadsQueryKey, + type ThreadTimelineTurnDetailsQueryIdentity, type ThreadTimelineTurnSummaryDetailsQueryIdentity, } from "./query-keys"; import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; @@ -1067,6 +1069,34 @@ export function useThreadTimelineTurnSummaryDetails( }); } +export function useThreadTimelineTurnDetails( + identity: ThreadTimelineTurnDetailsQueryIdentity, +) { + return useInfiniteQuery({ + queryKey: threadTimelineTurnDetailsQueryKey(identity), + queryFn: ({ pageParam, signal }) => + sdk.threads.timelineTurnDetails({ + ...(pageParam ? { cursor: pageParam } : {}), + signal, + threadId: requireThreadId( + identity.threadId, + "useThreadTimelineTurnDetails", + ), + turnId: identity.turnId, + }), + initialPageParam: null as string | null, + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: Boolean(identity.threadId) && Boolean(identity.turnId), + meta: { + errorMessage: "Failed to load turn details.", + showErrorToast: false, + }, + refetchOnMount: true, + staleTime: Infinity, + ...HEAVY_PAYLOAD_QUERY_POLICY, + }); +} + export function getLatestPendingInteraction( interactions: readonly PendingInteraction[] | undefined, ): PendingInteraction | null { diff --git a/apps/cli/package.json b/apps/cli/package.json index 37fb80f66e..fe7fbd9c5e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,6 +14,7 @@ "test": "vitest run" }, "dependencies": { + "@bb/client-core": "workspace:*", "@bb/config": "workspace:*", "@bb/core-ui": "workspace:*", "@bb/domain": "workspace:*", diff --git a/apps/cli/src/commands/thread/show.ts b/apps/cli/src/commands/thread/show.ts index 8f05f7f09b..f3a08c92ba 100644 --- a/apps/cli/src/commands/thread/show.ts +++ b/apps/cli/src/commands/thread/show.ts @@ -14,6 +14,7 @@ import { type WorkspaceStatus, } from "@bb/domain"; import type { BbSdk } from "@bb/sdk"; +import { prependOlderTimelineRows } from "@bb/client-core"; import type { EnvironmentDiffQuery, ThreadTimelineResponse, @@ -524,7 +525,10 @@ export function registerShowCommand( beforeAnchorSeq: String(page.olderCursor.anchorSeq), beforeAnchorId: page.olderCursor.anchorId, }); - rows = [...older.rows, ...rows]; + rows = prependOlderTimelineRows({ + olderRows: older.rows, + loadedRows: rows, + }); page = older.timelinePage; } const color = process.stdout.isTTY === true && !process.env.NO_COLOR; diff --git a/apps/mobile/src/data/thread-detail/index.ts b/apps/mobile/src/data/thread-detail/index.ts index c1f414d836..127ef92c30 100644 --- a/apps/mobile/src/data/thread-detail/index.ts +++ b/apps/mobile/src/data/thread-detail/index.ts @@ -5,7 +5,7 @@ export { useThreadDetailBootstrap, useThreadPendingInteractions, useThreadQueuedMessages, - useTimelineTurnSummaryDetails, + useTimelineTurnDetails, } from "./thread-detail-queries"; export { useThreadTimelineController } from "./use-thread-timeline-controller"; export { useChildThreadSummary } from "./use-child-thread-summary"; diff --git a/apps/mobile/src/data/thread-detail/thread-detail-queries.ts b/apps/mobile/src/data/thread-detail/thread-detail-queries.ts index 43370ee394..98875f1c7d 100644 --- a/apps/mobile/src/data/thread-detail/thread-detail-queries.ts +++ b/apps/mobile/src/data/thread-detail/thread-detail-queries.ts @@ -5,9 +5,12 @@ import type { ThreadQueuedMessageListResponse, ThreadTimelineResponse, ThreadWithIncludesResponse, - TimelineTurnSummaryDetailsResponse, } from "@bb/server-contract"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useProfileClient } from "@/app-shell/ProfilesProvider"; import { shouldRetryTransientReadQuery, @@ -20,8 +23,8 @@ import { threadPendingInteractionsQueryKey, threadQueuedMessagesQueryKey, threadTimelineQueryKey, - threadTimelineTurnSummaryDetailsQueryKey, - type ThreadTimelineTurnSummaryDetailsQueryIdentity, + threadTimelineTurnDetailsQueryKey, + type ThreadTimelineTurnDetailsQueryIdentity, } from "@/lib/query/query-keys"; import { requireEnabledQueryArg } from "../shared/query-helpers"; import { SESSION_STATIC_QUERY_POLICY } from "../shared/query-policies"; @@ -219,40 +222,30 @@ export function useThreadQueuedMessages( } /** - * Lazy children of one completed-turn summary row - * (`GET /threads/:id/timeline/turn-summary-details`). Immutable for the - * identity (turn + source sequence span), so it never goes stale; a history - * rewrite invalidates every window of the thread. + * Lazy children of one completed-turn summary row. The server owns page + * boundaries; the client cache identifies only the turn. + * A history rewrite invalidates every detail page for the thread. */ -export function useTimelineTurnSummaryDetails( - identity: ThreadTimelineTurnSummaryDetailsQueryIdentity, - options?: QueryOptions, +export function useTimelineTurnDetails( + identity: ThreadTimelineTurnDetailsQueryIdentity, ) { const { sdk } = useProfileClient(); - const enabled = - (options?.enabled ?? true) && - Boolean(identity.threadId) && - Boolean(identity.turnId); - - return useQuery({ - queryKey: threadTimelineTurnSummaryDetailsQueryKey(identity), - queryFn: ({ signal }) => - sdk.threads.timelineTurnSummaryDetails({ + return useInfiniteQuery({ + queryKey: threadTimelineTurnDetailsQueryKey(identity), + queryFn: ({ pageParam, signal }) => + sdk.threads.timelineTurnDetails({ + ...(pageParam ? { cursor: pageParam } : {}), threadId: requireEnabledQueryArg({ value: identity.threadId, - hookName: "useTimelineTurnSummaryDetails", + hookName: "useTimelineTurnDetails", argName: "thread id", }), - sourceSeqEnd: String(identity.sourceSeqEnd), - sourceSeqStart: String(identity.sourceSeqStart), turnId: identity.turnId, signal, }), - enabled, - meta: { - errorMessage: "Failed to load turn summary details.", - showErrorToast: false, - }, + initialPageParam: null as string | null, + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: Boolean(identity.threadId) && Boolean(identity.turnId), refetchOnMount: true, staleTime: Infinity, }); diff --git a/apps/mobile/src/lib/query/query-keys.ts b/apps/mobile/src/lib/query/query-keys.ts index 40c18c81ff..e0203b3dbf 100644 --- a/apps/mobile/src/lib/query/query-keys.ts +++ b/apps/mobile/src/lib/query/query-keys.ts @@ -143,19 +143,15 @@ type ThreadTimelineQueryKey = readonly [ typeof THREAD_TIMELINE_QUERY_KEY, string, ]; -/** Identity of one lazily loaded completed-turn detail window. */ -export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { - sourceSeqEnd: number; - sourceSeqStart: number; +export interface ThreadTimelineTurnDetailsQueryIdentity { threadId: string; turnId: string; } -type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [ +type ThreadTimelineTurnDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, string, - number, - number, + "pages", ]; type ThreadTimelineTurnSummaryDetailsQueryKeyPrefix = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, @@ -386,18 +382,15 @@ export function threadTimelineQueryKey( return [THREAD_TIMELINE_QUERY_KEY, threadId]; } -export function threadTimelineTurnSummaryDetailsQueryKey({ - sourceSeqEnd, - sourceSeqStart, +export function threadTimelineTurnDetailsQueryKey({ threadId, turnId, -}: ThreadTimelineTurnSummaryDetailsQueryIdentity): ThreadTimelineTurnSummaryDetailsQueryKey { +}: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey { return [ THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, threadId, turnId, - sourceSeqStart, - sourceSeqEnd, + "pages", ]; } diff --git a/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx b/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx index f9b384dce0..099f3601ca 100644 --- a/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx +++ b/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx @@ -1,11 +1,18 @@ -import { useCallback, useEffect, useState, type ReactElement } from "react"; -import { useTimelineTurnSummaryDetails } from "@/data/thread-detail"; -import type { ThreadTimelineTurnSummaryDetailsQueryIdentity } from "@/lib/query/query-keys"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactElement, +} from "react"; +import { mergeTimelineTurnDetailPages } from "@bb/client-core"; +import { useTimelineTurnDetails } from "@/data/thread-detail"; +import type { ThreadTimelineTurnDetailsQueryIdentity } from "@/lib/query/query-keys"; import type { TimelineListItem, TimelineTurnChildrenState } from "./rows"; interface TurnChildrenLoaderProps { itemKey: string; - identity: ThreadTimelineTurnSummaryDetailsQueryIdentity; + identity: ThreadTimelineTurnDetailsQueryIdentity; onChange: (itemKey: string, state: TimelineTurnChildrenState | null) => void; } @@ -19,18 +26,43 @@ function TurnChildrenLoader({ identity, onChange, }: TurnChildrenLoaderProps) { - const query = useTimelineTurnSummaryDetails(identity); - const data = query.data; - const isError = query.isError; + const query = useTimelineTurnDetails(identity); + const fetchNextPage = query.fetchNextPage; + const rows = useMemo( + () => + query.data + ? mergeTimelineTurnDetailPages( + query.data.pages.map((page) => page.rows), + ) + : undefined, + [query.data], + ); + const loadMore = useCallback(() => { + void fetchNextPage(); + }, [fetchNextPage]); useEffect(() => { - if (data) { - onChange(itemKey, { status: "loaded", rows: data.rows }); - } else if (isError) { + if (rows) { + onChange(itemKey, { + status: "loaded", + rows, + hasMore: query.hasNextPage, + loadingMore: query.isFetchingNextPage, + loadMore, + }); + } else if (query.isError) { onChange(itemKey, { status: "error" }); } else { onChange(itemKey, { status: "loading" }); } - }, [data, isError, itemKey, onChange]); + }, [ + itemKey, + loadMore, + onChange, + query.hasNextPage, + query.isError, + query.isFetchingNextPage, + rows, + ]); useEffect(() => () => onChange(itemKey, null), [itemKey, onChange]); return null; } @@ -58,7 +90,11 @@ export function useTurnChildrenMap(): { existing !== undefined && existing.status === state.status && (state.status !== "loaded" || - (existing.status === "loaded" && existing.rows === state.rows)) + (existing.status === "loaded" && + existing.rows === state.rows && + existing.hasMore === state.hasMore && + existing.loadingMore === state.loadingMore && + existing.loadMore === state.loadMore)) ) { return current; } @@ -90,8 +126,6 @@ export function renderTurnChildrenLoaders( key={item.key} itemKey={item.key} identity={{ - sourceSeqEnd: row.sourceSeqEnd, - sourceSeqStart: row.sourceSeqStart, threadId: threadId || row.threadId, turnId: row.turnId, }} diff --git a/apps/mobile/src/screens/thread/timeline/renderers/turn/TurnRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/turn/TurnRow.tsx index c3a256aa2f..571b920dd4 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/turn/TurnRow.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/turn/TurnRow.tsx @@ -1,6 +1,6 @@ import { View } from "react-native"; import { useTheme } from "@/theme"; -import { Spinner, Text } from "@/ui"; +import { Button, Spinner, Text } from "@/ui"; import { TIMELINE_ROW_DEPTH_INDENT_PX } from "../../FallbackTimelineRow"; import type { TimelineRowRendererProps } from "../../renderers"; import { @@ -13,7 +13,7 @@ import { isPastTimelineRow } from "../shared/row-dim"; * `turn` renderer: a completed turn's recap header ("Worked for 8m 14s") or * the live "Working" row. Expanding reveals the turn's rows as flattened * children one level in; turns outside the loaded window fetch them lazily - * (`useTimelineTurnSummaryDetails` through the list's loaders), so the row + * (`useTimelineTurnDetails` through the list's loaders), so the row * shows the load state under its header until they arrive. */ export function TurnRow({ @@ -54,6 +54,22 @@ export function TurnRow({ Failed to load turn details. Collapse and expand to retry. ) : null} + {expanded && item.lazyChildrenHasMore ? ( + + + + ) : null} ); } diff --git a/apps/mobile/src/screens/thread/timeline/rows.test.ts b/apps/mobile/src/screens/thread/timeline/rows.test.ts index 28b0187773..e10367760d 100644 --- a/apps/mobile/src/screens/thread/timeline/rows.test.ts +++ b/apps/mobile/src/screens/thread/timeline/rows.test.ts @@ -170,6 +170,9 @@ describe("buildTimelineListItems", () => { "t1", { status: "loaded", + hasMore: true, + loadingMore: false, + loadMore: () => undefined, rows: [ commandRow("t1c1", "pnpm build"), commandRow("t1c2", "pnpm test"), @@ -180,6 +183,7 @@ describe("buildTimelineListItems", () => { ]), }); expect(loaded[1]?.lazyChildren).toBe("loaded"); + expect(loaded[1]?.lazyChildrenHasMore).toBe(true); // Lazy children are a closed scope: trailing work collapses into a // step-summary like the web's lazy turn body. expect(loaded.slice(2).map((item) => [item.kind, item.depth])).toEqual([ diff --git a/apps/mobile/src/screens/thread/timeline/rows.ts b/apps/mobile/src/screens/thread/timeline/rows.ts index e5cebb1928..b50a87e429 100644 --- a/apps/mobile/src/screens/thread/timeline/rows.ts +++ b/apps/mobile/src/screens/thread/timeline/rows.ts @@ -127,6 +127,9 @@ interface TimelineListItemOfKind { expanded: boolean; /** Set on expanded turn rows whose children come from the lazy endpoint. */ lazyChildren: TimelineLazyChildrenStatus | null; + lazyChildrenHasMore: boolean; + lazyChildrenLoadingMore: boolean; + onLoadMoreLazyChildren: (() => void) | null; } export type TimelineListItem = { @@ -137,7 +140,13 @@ export type TimelineListItem = { export type TimelineTurnChildrenState = | { status: "loading" } | { status: "error" } - | { status: "loaded"; rows: readonly TimelineRow[] }; + | { + status: "loaded"; + rows: readonly TimelineRow[]; + hasMore: boolean; + loadingMore: boolean; + loadMore: () => void; + }; interface BuildTimelineListItemsArgs { rows: readonly TimelineRow[]; @@ -253,7 +262,10 @@ function isSameListItem(a: TimelineListItem, b: TimelineListItem): boolean { a.scopeActive === b.scopeActive && a.expandable === b.expandable && a.expanded === b.expanded && - a.lazyChildren === b.lazyChildren + a.lazyChildren === b.lazyChildren && + a.lazyChildrenHasMore === b.lazyChildrenHasMore && + a.lazyChildrenLoadingMore === b.lazyChildrenLoadingMore && + a.onLoadMoreLazyChildren === b.onLoadMoreLazyChildren ); } @@ -302,6 +314,9 @@ export function buildTimelineListItems({ ); const expanded = isExpanded(row.id); let lazyChildren: TimelineLazyChildrenStatus | null = null; + let lazyChildrenHasMore = false; + let lazyChildrenLoadingMore = false; + let onLoadMoreLazyChildren: (() => void) | null = null; let children: readonly ThreadTimelineViewRow[] | null = null; let childScopeActive = false; if (expanded) { @@ -327,6 +342,9 @@ export function buildTimelineListItems({ lazyChildren = "error"; } else { lazyChildren = "loaded"; + lazyChildrenHasMore = lazy.hasMore; + lazyChildrenLoadingMore = lazy.loadingMore; + onLoadMoreLazyChildren = lazy.loadMore; // Lazy turn children belong to a completed turn: a closed // scope, so trailing work collapses into a step-summary. children = buildTimelineViewRows(lazy.rows, { @@ -358,6 +376,9 @@ export function buildTimelineListItems({ expandable: isRowExpandable(row), expanded, lazyChildren, + lazyChildrenHasMore, + lazyChildrenLoadingMore, + onLoadMoreLazyChildren, } as TimelineListItem; const previous = previousItems?.get(key); const item = diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 6aa5e42cce..29fe3439ff 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -45,9 +45,11 @@ import { toThreadQueuedMessage } from "../../services/threads/thread-queued-mess import { buildThreadConversationOutline, buildThreadTimelineWithProfile, + buildTimelineTurnDetailsPage, buildTimelineTurnSummaryDetails, THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT, THREAD_TIMELINE_SEGMENT_LIMIT_MAX, + THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, } from "../../services/threads/timeline.js"; import type { ThreadTimelinePageKind, @@ -468,6 +470,25 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { ); }); + get(routes.timelineTurnDetails, (context, query) => { + const thread = requirePublicThread(deps.db, context.req.param("id")); + const includeProviderUnhandledOperations = + deps.config.isDevelopment || + getAppSettings(deps.db).showUnhandledProviderEvents; + return context.json( + buildTimelineTurnDetailsPage(deps.db, thread, { + ...(query.cursor ? { cursor: query.cursor } : {}), + eventLimit: THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, + includeProviderUnhandledOperations, + providerDisplayName: resolveThreadProviderDisplayName( + deps, + thread.providerId, + ), + turnId: query.turnId, + }), + ); + }); + get(routes.output, (context) => { requirePublicThread(deps.db, context.req.param("id")); return context.json({ diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index c742b6ba87..a60ed95b13 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -647,7 +647,7 @@ signatures (see "Looking up the exact API"). | Area | Methods | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storageLocation` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | +| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnDetails` `timelineTurnSummaryDetails` `storageFiles` `storageLocation` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | | `threadSections` | `list` `create` `update` `delete` | | `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | | `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` | diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index af4ed9330a..37e8095b28 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -1,6 +1,7 @@ import { buildThreadTimelineFromEvents, THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + buildThreadTimelineTurnDetailPageFromEvents, buildThreadTimelineTurnDetailsFromEvents, compactThreadTimelineSummaryEvents, type AcceptedClientRequestContext, @@ -20,6 +21,7 @@ import type { ThreadConversationOutlineAttachmentSummary, TimelineRow, TimelineSystemRow, + TimelineTurnDetailsResponse, ThreadTimelineResponse, TimelineTurnSummaryDetailsResponse, } from "@bb/server-contract"; @@ -33,6 +35,7 @@ import { getTimelineSegmentAnchorAtSequence, listContextWindowUsageRows, listRecentStoredEventRows, + readStoredTimelineWindowForwardPage, listStoredConversationOutlineEventRows, listStoredClientTurnRequestIdsInRange, listStoredEventRowsByParentToolCallIds, @@ -53,6 +56,7 @@ import { listTimelineSegmentAnchorsDescending, scopedItemRefKey, } from "@bb/db"; +import { z } from "zod"; import type { DbConnection, InlineOutputCharLimit, @@ -161,10 +165,25 @@ interface BuildTimelineTurnSummaryDetailsOptions extends TimelineTurnSummarySele providerDisplayName?: string; } +interface BuildTimelineTurnDetailsPageOptions { + cursor?: string; + eventLimit: number; + includeProviderUnhandledOperations: boolean; + providerDisplayName?: string; + turnId: string; +} + +interface BuildTimelineTurnSummaryDetailsRangeOptions extends BuildTimelineTurnSummaryDetailsOptions { + preloadedEventRows?: readonly StoredEventRow[]; + resourceKind: "exact-range" | "page"; +} + export const THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT = 20; export const THREAD_TIMELINE_SEGMENT_LIMIT_MAX = 100; +export const THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT = 250; + /** * Driver rows and decoded events can use several times their stored JSON size. * Bound each page before either representation enters the V8 heap. @@ -1566,7 +1585,14 @@ function buildSequencePageTimelineRows( return [ { ...row, - id: `${row.id}${suffix}`, + // A finished turn summary is one logical row even when transport + // budgets split its source events across pages. Other row kinds keep + // page-local identities because equal ids can represent closure + // context rather than disjoint pieces of one row. + id: + row.completedAt === null || row.status === "pending" + ? `${row.id}${suffix}` + : row.id, sourceSeqEnd, sourceSeqStart, }, @@ -1703,7 +1729,20 @@ function buildThreadTimelineInternal( ); profile.contextWindowEventRowCount = contextWindowUsageRows.length; } + const byteWindowSequenceEnd = eventSelection.byteWindowSequenceEnd; const commonProjectionOptions = { + contextOnlyCompletedTurnIds: + byteWindowSequenceEnd === null + ? undefined + : new Set( + rawEventRows.flatMap((row) => + row.type === "turn/completed" && + row.turnId !== null && + row.sequence > byteWindowSequenceEnd + ? [row.turnId] + : [], + ), + ), includeProviderUnhandledOperations, isLatestPage: options.page.kind === "latest", providerDisplayName: options.providerDisplayName, @@ -1947,10 +1986,10 @@ export function buildThreadConversationOutline( }); } -export function buildTimelineTurnSummaryDetails( +function buildTimelineTurnSummaryDetailsRange( db: DbConnection, thread: Thread, - options: BuildTimelineTurnSummaryDetailsOptions, + options: BuildTimelineTurnSummaryDetailsRangeOptions, ): TimelineTurnSummaryDetailsResponse { if (options.sourceSeqStart > options.sourceSeqEnd) { throw new ApiError( @@ -1968,31 +2007,39 @@ export function buildTimelineTurnSummaryDetails( sequenceStart: options.sourceSeqStart, threadId: thread.id, }; - const fullDetailsFloor = findStoredTimelineWindowByteBudgetFloor(db, { - ...detailsWindow, - maxDataBytes: THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, - maxInlineOutputChars: null, - }); - let detailsInlineOutputLimit: InlineOutputCharLimit = null; - if (fullDetailsFloor.kind !== "fits") { - detailsInlineOutputLimit = DEFAULT_MAX_INLINE_OUTPUT_CHARS; - const cappedDetailsFloor = findStoredTimelineWindowByteBudgetFloor(db, { + let detailsInlineOutputLimit: InlineOutputCharLimit = + options.preloadedEventRows === undefined + ? null + : DEFAULT_MAX_INLINE_OUTPUT_CHARS; + let exactEventRows: readonly StoredEventRow[]; + if (options.preloadedEventRows !== undefined) { + exactEventRows = options.preloadedEventRows; + } else { + const fullDetailsFloor = findStoredTimelineWindowByteBudgetFloor(db, { ...detailsWindow, maxDataBytes: THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, - maxInlineOutputChars: detailsInlineOutputLimit, + maxInlineOutputChars: null, }); - if (cappedDetailsFloor.kind !== "fits") { - throw new ApiError( - 413, - "timeline_window_too_large", - "Timeline turn details exceed the safe response limit", - ); + if (fullDetailsFloor.kind !== "fits") { + detailsInlineOutputLimit = DEFAULT_MAX_INLINE_OUTPUT_CHARS; + const cappedDetailsFloor = findStoredTimelineWindowByteBudgetFloor(db, { + ...detailsWindow, + maxDataBytes: THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, + maxInlineOutputChars: detailsInlineOutputLimit, + }); + if (cappedDetailsFloor.kind !== "fits") { + throw new ApiError( + 413, + "timeline_window_too_large", + "Timeline turn details exceed the safe response limit", + ); + } } + exactEventRows = listStoredTimelineWindowEventRows(db, { + ...detailsWindow, + maxInlineOutputChars: detailsInlineOutputLimit, + }); } - const exactEventRows = listStoredTimelineWindowEventRows(db, { - ...detailsWindow, - maxInlineOutputChars: detailsInlineOutputLimit, - }); const clientRequestIds = listStoredClientTurnRequestIdsInRange(db, { threadId: thread.id, seqStart: options.sourceSeqStart, @@ -2113,7 +2160,7 @@ export function buildTimelineTurnSummaryDetails( : sourceSeqStart, sourceRange.sourceSeqStart, ); - const children = buildThreadTimelineTurnDetailsFromEvents({ + const projectionArgs = { events: eventRowsWithBackgroundTaskState.map((row) => toThreadEventWithMeta(row), ), @@ -2126,7 +2173,15 @@ export function buildTimelineTurnSummaryDetails( threadName: thread.title ?? thread.titleFallback ?? "", workspaceRoot: resolveThreadWorkspaceRoot(db, thread), }, - }); + } satisfies Parameters[0]; + + if (options.resourceKind === "page") { + return { + rows: buildThreadTimelineTurnDetailPageFromEvents(projectionArgs), + }; + } + + const children = buildThreadTimelineTurnDetailsFromEvents(projectionArgs); if (children.kind !== "missing-match") { return { @@ -2138,3 +2193,149 @@ export function buildTimelineTurnSummaryDetails( `Timeline turn summary details could not match range ${options.sourceSeqStart}-${options.sourceSeqEnd}`, ); } + +export function buildTimelineTurnSummaryDetails( + db: DbConnection, + thread: Thread, + options: BuildTimelineTurnSummaryDetailsOptions, +): TimelineTurnSummaryDetailsResponse { + return buildTimelineTurnSummaryDetailsRange(db, thread, { + ...options, + resourceKind: "exact-range", + }); +} + +interface TurnDetailsCursorPayload { + sequenceStart: number; + threadId: string; + turnId: string; + version: 1; +} + +const turnDetailsCursorPayloadSchema = z.object({ + sequenceStart: z.number().int().nonnegative(), + threadId: z.string().min(1), + turnId: z.string().min(1), + version: z.literal(1), +}); + +function encodeTurnDetailsCursor(payload: TurnDetailsCursorPayload): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function parseTurnDetailsCursor( + cursor: string, + expected: Omit, +): number { + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + } catch { + throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); + } + const parsed = turnDetailsCursorPayloadSchema.safeParse(decoded); + if ( + !parsed.success || + parsed.data.version !== expected.version || + parsed.data.threadId !== expected.threadId || + parsed.data.turnId !== expected.turnId + ) { + throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); + } + return parsed.data.sequenceStart; +} + +function resolveCompletedTurnDetailBounds( + db: DbConnection, + threadId: string, + turnId: string, +): TimelineTurnSummarySelection { + const started = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + sequenceCutoff: Number.MAX_SAFE_INTEGER, + threadId, + turnIds: [turnId], + })[0]; + const completed = listStoredTurnCompletedRowsByTurnIds(db, { + threadId, + turnIds: [turnId], + }).at(-1); + if (!started || !completed || started.sequence > completed.sequence) { + throw new ApiError( + 400, + "invalid_request", + `Cannot paginate details for incomplete turn ${turnId}`, + ); + } + return { + sourceSeqEnd: completed.sequence, + sourceSeqStart: started.sequence, + turnId, + }; +} + +export function buildTimelineTurnDetailsPage( + db: DbConnection, + thread: Thread, + options: BuildTimelineTurnDetailsPageOptions, +): TimelineTurnDetailsResponse { + const bounds = resolveCompletedTurnDetailBounds( + db, + thread.id, + options.turnId, + ); + const cursorIdentity = { + threadId: thread.id, + turnId: options.turnId, + version: 1 as const, + }; + const sourceSeqStart = options.cursor + ? parseTurnDetailsCursor(options.cursor, cursorIdentity) + : bounds.sourceSeqStart; + if ( + sourceSeqStart < bounds.sourceSeqStart || + sourceSeqStart > bounds.sourceSeqEnd + ) { + throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); + } + + const page = readStoredTimelineWindowForwardPage(db, { + beforeSequence: bounds.sourceSeqEnd + 1, + excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, + maxDataBytes: THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, + maxEventCount: options.eventLimit, + maxInlineOutputChars: DEFAULT_MAX_INLINE_OUTPUT_CHARS, + sequenceStart: sourceSeqStart, + threadId: thread.id, + }); + if (page.kind === "single-event-too-large") { + throw new ApiError( + 413, + "timeline_window_too_large", + `Timeline turn detail event ${page.sequence} exceeds the safe response limit`, + ); + } + + const sourceSeqEnd = page.nextSequenceStart + ? page.nextSequenceStart - 1 + : bounds.sourceSeqEnd; + const details = buildTimelineTurnSummaryDetailsRange(db, thread, { + includeProviderUnhandledOperations: + options.includeProviderUnhandledOperations, + preloadedEventRows: page.rows, + providerDisplayName: options.providerDisplayName, + resourceKind: "page", + sourceSeqEnd, + sourceSeqStart, + turnId: options.turnId, + }); + return { + rows: details.rows, + nextCursor: + page.nextSequenceStart === null + ? null + : encodeTurnDetailsCursor({ + ...cursorIdentity, + sequenceStart: page.nextSequenceStart, + }), + }; +} diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 41f204bd43..508de9a490 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -33,6 +33,7 @@ import { threadStorageLocationResponseSchema, threadTimelineResponseSchema, threadWithIncludesResponseSchema, + timelineTurnDetailsResponseSchema, timelineTurnSummaryDetailsResponseSchema, uploadedPromptAttachmentSchema, } from "@bb/server-contract"; @@ -1078,6 +1079,18 @@ describe("public thread data routes", () => { expect(detailRow.workKind).toBe("tool"); expect(detailRow.callId).toBe("tool-1"); } + + const pageResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/timeline/turn-details?turnId=${turnRow.turnId}`, + ); + expect(pageResponse.status).toBe(200); + const page = timelineTurnDetailsResponseSchema.parse( + await readJson(pageResponse), + ); + expect(page.nextCursor).toBeNull(); + expect(page.rows.map((row) => row.id)).toEqual( + toolDetails.rows.map((row) => row.id), + ); }); }); diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index 4cebc95e02..035b754f0d 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -23,9 +23,11 @@ import type { } from "@bb/server-contract"; import { buildThreadTimeline, + buildTimelineTurnDetailsPage, buildTimelineTurnSummaryDetails, buildThreadTimelineWithProfile, THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, + THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, } from "../../../src/services/threads/timeline.js"; /** Larger than any thread these tests build, so the budget never binds. */ @@ -85,6 +87,8 @@ function backgroundTaskData(status: "pending" | "completed"): string { } interface SeedOptions { + /** Emit an assistant message before this item in the last turn. */ + assistantBeforeItem?: number; /** * Start a workflow background task at the top of the last turn, and complete * it there too when `"completed"`. Its rows sit far below any in-turn cut. @@ -224,6 +228,24 @@ function seedTurns( ); const deferred: number[] = []; for (let item = 0; item < items; item += 1) { + if (isLastTurn && options.assistantBeforeItem === item) { + const itemId = `${turnId}-assistant`; + push({ + type: "item/completed", + scope: turnScope(turnId), + providerThreadId, + itemId, + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: itemId, + text: "Intermediate update.", + }, + }), + }); + } const itemId = `${turnId}-item-${item}`; const command = options.commandChars === undefined @@ -463,18 +485,21 @@ function buildNestedPage( function collectCommandCallIds( rows: readonly TimelineRow[], target: Set, -): void { +): number { + let count = 0; for (const row of rows) { if (row.kind === "work" && row.workKind === "command") { target.add(row.callId); + count += 1; } if (row.kind === "work" && row.workKind === "delegation") { - collectCommandCallIds(row.childRows, target); + count += collectCommandCallIds(row.childRows, target); } if (row.kind === "turn" && row.children !== null) { - collectCommandCallIds(row.children, target); + count += collectCommandCallIds(row.children, target); } } + return count; } interface WalkResult { @@ -823,13 +848,13 @@ describe("in-turn timeline windows", () => { it("pages through a finished turn that exceeds the event-data byte limit", () => { const { db, thread } = setup(); seedTurns(db, thread, { + assistantBeforeItem: 20, commandChars: 25_000, completeLastTurn: true, itemsPerTurn: [BYTE_WINDOW_ITEM_COUNT], }); const commandCallIds = new Set(); - const expandedCommandCallIds = new Set(); const turnRowIds = new Set(); let cursor: TimelinePaginationCursor | null = null; let pages = 0; @@ -837,26 +862,17 @@ describe("in-turn timeline windows", () => { const page = buildNestedPage(db, thread, LARGE_BUDGET, cursor); pages += 1; collectCommandCallIds(page.response.rows, commandCallIds); + expect( + page.response.rows.some( + (row) => row.turnId === "turn-1" && row.kind === "work", + ), + ).toBe(false); for (const row of page.response.rows) { if (row.kind !== "turn") { continue; } expect(row.status).toBe("completed"); - expect(turnRowIds.has(row.id)).toBe(false); turnRowIds.add(row.id); - const details = buildTimelineTurnSummaryDetails(db, thread, { - includeProviderUnhandledOperations: false, - sourceSeqEnd: row.sourceSeqEnd, - sourceSeqStart: row.sourceSeqStart, - turnId: row.turnId, - }); - const pageDetailCallIds = new Set(); - collectCommandCallIds(details.rows, pageDetailCallIds); - expect(pageDetailCallIds.size).toBeGreaterThan(0); - expect(pageDetailCallIds.size).toBeLessThan(BYTE_WINDOW_ITEM_COUNT); - for (const callId of pageDetailCallIds) { - expandedCommandCallIds.add(callId); - } } expect(page.profile.eventDataBytes, `page ${pages}`).toBeLessThanOrEqual( THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, @@ -872,8 +888,31 @@ describe("in-turn timeline windows", () => { expect(pages).toBeGreaterThan(2); expect(commandCallIds.size).toBe(BYTE_WINDOW_ITEM_COUNT); + expect(turnRowIds.size).toBe(1); + + const expandedCommandCallIds = new Set(); + let expandedCommandRowCount = 0; + let detailCursor: string | undefined; + let detailPages = 0; + do { + const detail = buildTimelineTurnDetailsPage(db, thread, { + ...(detailCursor ? { cursor: detailCursor } : {}), + eventLimit: THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, + includeProviderUnhandledOperations: false, + turnId: "turn-1", + }); + detailPages += 1; + expandedCommandRowCount += collectCommandCallIds( + detail.rows, + expandedCommandCallIds, + ); + detailCursor = detail.nextCursor ?? undefined; + expect(detailPages).toBeLessThan(10); + } while (detailCursor); + + expect(detailPages).toBeGreaterThan(1); + expect(expandedCommandRowCount).toBe(BYTE_WINDOW_ITEM_COUNT); expect(expandedCommandCallIds.size).toBe(BYTE_WINDOW_ITEM_COUNT); - expect(turnRowIds.size).toBe(pages); }, 15_000); it("keeps latest byte-page row identities stable while a turn grows", () => { @@ -1240,7 +1279,7 @@ describe("timeline segment anchors", () => { }); describe("timeline window event exclusions", () => { - it("never reads workspace diff events into a window", () => { + it("never reads non-projecting diff or rate-limit events into a window", () => { const { db, thread } = setup(); seedTurns(db, thread, { completeLastTurn: true, itemsPerTurn: [5] }); const withoutDiffs = buildPage(db, thread, LARGE_BUDGET, null); @@ -1259,6 +1298,17 @@ describe("timeline window event exclusions", () => { parentToolCallId: null, data: JSON.stringify({ diff: "x".repeat(50_000) }), }, + { + threadId: thread.id, + sequence: 501, + type: "provider/rateLimits/updated", + scope: threadScope(), + providerThreadId, + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({}), + }, ]); const withDiffs = buildPage(db, thread, LARGE_BUDGET, null); diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts index 97d6bfcd95..3cd0d2292b 100644 --- a/packages/client-core/src/timeline/timeline-merge.ts +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -98,12 +98,47 @@ function appendTimelineRowsPreservingOrder( target: TimelineRow[], rows: readonly TimelineRow[], ): void { - const seenIds = new Set(target.map((row) => row.id)); + const indexById = new Map(target.map((row, index) => [row.id, index])); for (const row of rows) { - if (seenIds.has(row.id)) { + const existingIndex = indexById.get(row.id); + if (existingIndex !== undefined) { + const existing = target[existingIndex]; + if ( + existing?.kind === "turn" && + row.kind === "turn" && + existing.completedAt !== null && + row.completedAt !== null && + (existing.sourceSeqEnd < row.sourceSeqStart || + row.sourceSeqEnd < existing.sourceSeqStart) + ) { + const ordered = + existing.sourceSeqStart <= row.sourceSeqStart + ? [existing, row] + : [row, existing]; + const children = [ + ...new Map( + ordered + .flatMap((part) => part.children ?? []) + .map((child) => [child.id, child]), + ).values(), + ]; + target[existingIndex] = { + ...ordered[1], + children: + existing.children === null && row.children === null + ? null + : children, + completedAt: Math.max(existing.completedAt, row.completedAt), + createdAt: Math.min(existing.createdAt, row.createdAt), + sourceSeqEnd: Math.max(existing.sourceSeqEnd, row.sourceSeqEnd), + sourceSeqStart: Math.min(existing.sourceSeqStart, row.sourceSeqStart), + startedAt: Math.min(existing.startedAt, row.startedAt), + summaryCount: existing.summaryCount + row.summaryCount, + }; + } continue; } - seenIds.add(row.id); + indexById.set(row.id, target.length); target.push(row); } } @@ -174,6 +209,61 @@ export function prependOlderTimelineRows({ return rows; } +/** + * Combines forward detail pages into the logical rows they represent. A + * delegation can span page boundaries, so each page may project the same + * delegation shell with a different bounded set of children. + */ +export function mergeTimelineTurnDetailPages( + pages: readonly (readonly TimelineRow[])[], +): TimelineRow[] { + const rows: TimelineRow[] = []; + const indexById = new Map(); + for (const page of pages) { + for (const row of page) { + const existingIndex = indexById.get(row.id); + if (existingIndex === undefined) { + indexById.set(row.id, rows.length); + rows.push(row); + continue; + } + const existing = rows[existingIndex]; + if ( + existing?.kind === "work" && + existing.workKind === "delegation" && + row.kind === "work" && + row.workKind === "delegation" + ) { + rows[existingIndex] = { + ...row, + childRows: mergeTimelineTurnDetailPages([ + existing.childRows, + row.childRows, + ]), + completedAt: + existing.completedAt === null + ? row.completedAt + : row.completedAt === null + ? existing.completedAt + : Math.max(existing.completedAt, row.completedAt), + createdAt: Math.min(existing.createdAt, row.createdAt), + sourceSeqEnd: Math.max(existing.sourceSeqEnd, row.sourceSeqEnd), + sourceSeqStart: Math.min( + existing.sourceSeqStart, + row.sourceSeqStart, + ), + startedAt: Math.min(existing.startedAt, row.startedAt), + }; + continue; + } + // Whole-item ownership makes the later page authoritative for ordinary + // rows whose lifecycle context caused the same id to appear twice. + rows[existingIndex] = row; + } + } + return rows; +} + export function mergeLatestTimelineRows({ latestRows, latestWindowStartSequence, diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index 353da9243a..177e9b3774 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ThreadTimelineResponse, TimelineCommandWorkRow, + TimelineDelegationWorkRow, TimelinePaginationCursor, TimelineRow, TimelineTurnRow, @@ -10,6 +11,7 @@ import type { import { mergeLoadedTimelineWithLatest, mergeLatestTimelineRows, + mergeTimelineTurnDetailPages, prependOlderTimelineRows, recoverLoadedTimelineAfterStaleCursor, type LoadedTimelineState, @@ -101,6 +103,34 @@ function turnSummaryRow(args: TimelineTurnTestRowArgs): TimelineTurnRow { }; } +function delegationRow( + id: string, + sequence: number, + childRows: TimelineRow[], +): TimelineDelegationWorkRow { + return { + id, + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: sequence, + sourceSeqEnd: sequence + 1, + startedAt: sequence, + createdAt: sequence, + kind: "work", + workKind: "delegation", + status: "completed", + callId: id, + toolName: "Agent", + childRef: null, + background: false, + subagentType: null, + description: "Delegated work", + output: "", + completedAt: sequence + 1, + childRows, + }; +} + function makeTimelineResponse( rows: TimelineRow[], olderCursor: TimelinePaginationCursor | null, @@ -183,7 +213,7 @@ describe("timeline page row merging", () => { ]); }); - it("keeps distinct byte-budget slices of one finished turn", () => { + it("coalesces disjoint transport slices of one finished turn", () => { const olderCommands = [ commandRow({ id: "command-1", sequence: 10 }), commandRow({ id: "command-2", sequence: 11 }), @@ -193,13 +223,15 @@ describe("timeline page row merging", () => { commandRow({ id: "command-4", sequence: 21 }), ]; const olderSlice = turnSummaryRow({ - id: "turn-1:sequence-page:10", + id: "turn-1", sequence: 10, + endSequence: 11, children: olderCommands, }); const latestSlice = turnSummaryRow({ - id: "turn-1:sequence-page:20", + id: "turn-1", sequence: 20, + endSequence: 21, children: latestCommands, }); @@ -208,10 +240,17 @@ describe("timeline page row merging", () => { loadedRows: [latestSlice], }); - expect(rows.map((row) => row.id)).toEqual([ - "turn-1:sequence-page:10", - "turn-1:sequence-page:20", - ]); + expect(rows.map((row) => row.id)).toEqual(["turn-1"]); + expect(rows[0]).toEqual( + expect.objectContaining({ + completedAt: 20, + createdAt: 10, + sourceSeqStart: 10, + sourceSeqEnd: 21, + startedAt: 10, + summaryCount: 2, + }), + ); expect( rows.flatMap((row) => row.kind === "turn" && row.children !== null @@ -221,6 +260,37 @@ describe("timeline page row merging", () => { ).toEqual(["command-1", "command-2", "command-3", "command-4"]); }); + it("merges a delegation shell repeated across forward detail pages", () => { + const rows = mergeTimelineTurnDetailPages([ + [ + delegationRow("delegation-1", 10, [ + commandRow({ id: "command-1", sequence: 11 }), + ]), + ], + [ + delegationRow("delegation-1", 20, [ + commandRow({ id: "command-2", sequence: 21 }), + ]), + ], + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual( + expect.objectContaining({ + id: "delegation-1", + sourceSeqStart: 10, + sourceSeqEnd: 21, + }), + ); + expect( + rows.flatMap((row) => + row.kind === "work" && row.workKind === "delegation" + ? row.childRows.map((child) => child.id) + : [], + ), + ).toEqual(["command-1", "command-2"]); + }); + it("replaces a byte-cut latest page while an unfinished turn grows", () => { const loadedRows = [15, 16, 17, 18].map((sequence) => commandRow({ diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 717d823d47..d24dcaffde 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1234,6 +1234,25 @@ export interface FindStoredTimelineWindowByteBudgetFloorArgs maxDataBytes: number; } +export interface ReadStoredTimelineWindowForwardPageArgs + extends ListStoredTimelineWindowEventRowsArgs { + beforeSequence: number; + maxDataBytes: number; + maxEventCount: number; +} + +export type StoredTimelineWindowForwardPage = + | { + kind: "page"; + nextSequenceStart: number | null; + rows: StoredEventRow[]; + } + | { + dataBytes: number; + kind: "single-event-too-large"; + sequence: number; + }; + export type StoredTimelineWindowByteBudgetFloor = | { eventDataBytes: number; kind: "fits" } | { eventDataBytes: number; kind: "floor"; sequenceStart: number } @@ -3109,11 +3128,64 @@ export function listStoredTimelineWindowEventRows( args: ListStoredTimelineWindowEventRowsArgs, ): StoredEventRow[] { return db - .select(storedEventRowFieldsWithInlineOutputLimit(args.maxInlineOutputChars)) + .select( + storedEventRowFieldsWithInlineOutputLimit(args.maxInlineOutputChars), + ) + .from(events) + .where(and(...storedTimelineWindowConditions(args))) + .orderBy(events.sequence) + .all(); +} + +/** + * Reads the oldest bounded prefix of a sequence range in one query. Completed + * turn details use this instead of first measuring a range and then reading it. + */ +export function readStoredTimelineWindowForwardPage( + db: DbConnection, + args: ReadStoredTimelineWindowForwardPageArgs, +): StoredTimelineWindowForwardPage { + const fields = storedEventRowFieldsWithInlineOutputLimit( + args.maxInlineOutputChars, + ); + const data = storedTimelineWindowDataColumn(args.maxInlineOutputChars); + const candidates = db + .select({ + ...fields, + dataBytes: sql`length(CAST(${data} AS BLOB))`.as("data_bytes"), + }) .from(events) .where(and(...storedTimelineWindowConditions(args))) .orderBy(events.sequence) + .limit(args.maxEventCount + 1) .all(); + + const rows: StoredEventRow[] = []; + let dataBytes = 0; + for (const candidate of candidates) { + if ( + rows.length === args.maxEventCount || + dataBytes + candidate.dataBytes > args.maxDataBytes + ) { + if (rows.length === 0) { + return { + dataBytes: candidate.dataBytes, + kind: "single-event-too-large", + sequence: candidate.sequence, + }; + } + return { + kind: "page", + nextSequenceStart: candidate.sequence, + rows, + }; + } + const { dataBytes: _dataBytes, ...row } = candidate; + rows.push(row); + dataBytes += candidate.dataBytes; + } + + return { kind: "page", nextSequenceStart: null, rows }; } function listLatestRowsForContextWindowUsage( diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 9c9f1c6d70..c902d9033b 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -257,6 +257,7 @@ export { listTimelineSegmentAnchorsDescending, findTimelineWindowBudgetFloorSequence, findStoredTimelineWindowByteBudgetFloor, + readStoredTimelineWindowForwardPage, getStoredEventRowsByParentToolCallIdsDataBytes, findUnfinishedTurnCoveringSequence, hasParentedEventCrossingSequence, @@ -305,6 +306,7 @@ export type { ScopedItemRef, StoredEventRow, StandardTimelineSegmentAnchorRow, + StoredTimelineWindowForwardPage, ThreadClientTurnRequestKey, StoredTurnRequestEventRow, } from "./events.js"; diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index d1a25551d7..4d83971bea 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -37,6 +37,8 @@ import type { ThreadTabsResponse, ThreadTimelineResponse, ThreadWithIncludesResponse, + TimelineTurnDetailsQuery, + TimelineTurnDetailsResponse, TimelineTurnSummaryDetailsResponse, ThreadOpenFile, ThreadOpenSplit, @@ -147,6 +149,7 @@ export type ThreadDefaultExecutionOptionsResult = ResolvedThreadExecutionOptions export type ThreadConversationOutlineResult = ThreadConversationOutlineResponse; export type ThreadTimelineTurnSummaryDetailsResult = TimelineTurnSummaryDetailsResponse; +export type ThreadTimelineTurnDetailsResult = TimelineTurnDetailsResponse; export interface ThreadSpawnBaseArgs extends Omit< CreateThreadRequest, @@ -253,6 +256,11 @@ export interface ThreadTimelineTurnSummaryDetailsArgs extends TimelineTurnSummar threadId: string; } +export interface ThreadTimelineTurnDetailsArgs extends TimelineTurnDetailsQuery { + signal?: AbortSignal; + threadId: string; +} + export interface ThreadTabsUpdateArgs extends UpdateThreadTabsRequest { threadId: string; } @@ -472,6 +480,9 @@ export interface ThreadsArea { stop(args: ThreadActionArgs): Promise; tabs: ThreadTabsArea; timeline(args: ThreadTimelineArgs): Promise; + timelineTurnDetails( + args: ThreadTimelineTurnDetailsArgs, + ): Promise; timelineTurnSummaryDetails( args: ThreadTimelineTurnSummaryDetailsArgs, ): Promise; @@ -1113,6 +1124,20 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ), ); }, + async timelineTurnDetails(input) { + return transport.readJson( + transport.api.v1.threads[":id"].timeline["turn-details"].$get( + { + param: { id: input.threadId }, + query: { + turnId: input.turnId, + ...(input.cursor ? { cursor: input.cursor } : {}), + }, + }, + ...signalRequestArgs(input.signal), + ), + ); + }, async timelineTurnSummaryDetails(input) { return transport.readJson( transport.api.v1.threads[":id"].timeline["turn-summary-details"].$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 6389b7638b..cd309582b3 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -384,6 +384,7 @@ type ExpectedThreadsKey = | "storagePaths" | "tabs" | "timeline" + | "timelineTurnDetails" | "timelineTurnSummaryDetails" | "unarchive" | "unpin" diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 280e7c6795..215b221dde 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -737,6 +737,14 @@ export type TimelineTurnSummaryDetailsQuery = z.infer< typeof timelineTurnSummaryDetailsQuerySchema >; +export const timelineTurnDetailsQuerySchema = z.object({ + turnId: z.string().min(1), + cursor: z.string().min(1).optional(), +}); +export type TimelineTurnDetailsQuery = z.infer< + typeof timelineTurnDetailsQuerySchema +>; + export const threadEventsQuerySchema = z .object({ afterSeq: z.string().regex(/^\d+$/), @@ -824,6 +832,14 @@ export type TimelineTurnSummaryDetailsResponse = z.infer< typeof timelineTurnSummaryDetailsResponseSchema >; +export const timelineTurnDetailsResponseSchema = z.object({ + rows: z.array(timelineRowSchema), + nextCursor: z.string().min(1).nullable(), +}); +export type TimelineTurnDetailsResponse = z.infer< + typeof timelineTurnDetailsResponseSchema +>; + export const threadTimelineResponseSchema = z.object({ rows: z.array(timelineRowSchema), activePromptMode: threadTimelineActivePromptModeSchema.nullable(), diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index e2033881c1..577930ae4c 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -195,6 +195,8 @@ import type { ThreadTimelineQuery, ThreadTimelineResponse, ThreadWithIncludesResponse, + TimelineTurnDetailsQuery, + TimelineTurnDetailsResponse, TimelineTurnSummaryDetailsQuery, TimelineTurnSummaryDetailsResponse, UpdateEnvironmentRequest, @@ -295,6 +297,7 @@ import { terminalOutputQuerySchema, terminalResizeRequestSchema, threadTimelineQuerySchema, + timelineTurnDetailsQuerySchema, systemCliSkillsStatusQuerySchema, systemInstallCliSkillsRequestSchema, timelineTurnSummaryDetailsQuerySchema, @@ -1231,6 +1234,14 @@ export const publicApiRoutes = { ), response: jsonResponse(), }), + timelineTurnDetails: defineRoute({ + path: "/threads/:id/timeline/turn-details", + method: "get", + request: queryRequest( + timelineTurnDetailsQuerySchema, + ), + response: jsonResponse(), + }), output: defineRoute({ path: "/threads/:id/output", method: "get", diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index dab2fe5e6f..64215618c6 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -33,6 +33,7 @@ import { terminalWebSocketQuerySchema, threadListResponseSchema, threadPendingInteractionsResponseSchema, + timelineTurnDetailsResponseSchema, timelineTurnSummaryDetailsResponseSchema, updateQueuedMessageRequestSchema, updateEnvironmentRequestSchema, @@ -254,6 +255,11 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadTimelineQuerySchema.afterSequence", ], }, + { + reason: + "The initial completed-turn detail request omits a cursor; continuation requests carry the opaque cursor returned by the server.", + fields: ["timelineTurnDetailsQuerySchema.cursor"], + }, { reason: "Timeline responses omit context-window usage when the provider did not report it.", @@ -1071,6 +1077,12 @@ describe("server-contract canonical schemas", () => { ).toEqual({ rows: [], }); + expect( + timelineTurnDetailsResponseSchema.parse({ + rows: [], + nextCursor: "cursor-2", + }), + ).toEqual({ rows: [], nextCursor: "cursor-2" }); }); it("normalizes the deprecated writable alias without widening readonly", () => { @@ -1609,6 +1621,12 @@ describe("server-contract clients", () => { }, }).pathname, ).toBe("/api/v1/threads/thr_123/timeline/turn-summary-details"); + expect( + publicClient.threads[":id"].timeline["turn-details"].$url({ + param: { id: "thr_123" }, + query: { turnId: "turn_123" }, + }).pathname, + ).toBe("/api/v1/threads/thr_123/timeline/turn-details"); expect( publicClient.threads[":id"]["thread-storage"].files.$url({ param: { id: "thr_123" }, @@ -1819,6 +1837,9 @@ describe("server-contract clients", () => { contract.threadPendingInteractionsResponseSchema, threadTimelineQuerySchema: contract.threadTimelineQuerySchema, threadTimelineResponseSchema: contract.threadTimelineResponseSchema, + timelineTurnDetailsQuerySchema: contract.timelineTurnDetailsQuerySchema, + timelineTurnDetailsResponseSchema: + contract.timelineTurnDetailsResponseSchema, timelineTurnSummaryDetailsQuerySchema: contract.timelineTurnSummaryDetailsQuerySchema, timelineTurnSummaryDetailsRequestSchema: diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 03942f8853..4c32d1022b 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -73,6 +73,7 @@ import { buildTimelineErrorDisplay } from "./error-display.js"; type ThreadTimelineTurnMessageDetail = "summary" | "full"; interface ThreadTimelineFromEventsBaseOptions { + contextOnlyCompletedTurnIds?: ReadonlySet; contextOnlyToolCallIds?: ReadonlySet; includeProviderUnhandledOperations: boolean; /** @@ -170,6 +171,7 @@ type ThreadTimelineTurnDetailsFromEventsResult = }; interface BuildTurnRowsArgs { + contextOnlyCompletedTurnIds?: ReadonlySet; includeNestedRows: boolean; rowIdPrefix: string; turn: EventProjectionTurn; @@ -187,7 +189,8 @@ interface BuildTurnSummaryRowArgs { completedAt: number | null; includeNestedRows: boolean; rowIdPrefix: string; - segmentIndex: number | null; + rowIdSegmentIndex: number | null; + sourceBounds: "messages" | "turn"; sourceMessages: EventProjectionMessage[]; sourceRows: TimelineRow[]; startedAt: number; @@ -204,6 +207,7 @@ interface BuildCompletedTurnSummaryRowsArgs { } interface BuildTimelineRowsOptions { + contextOnlyCompletedTurnIds?: ReadonlySet; includeNestedRows: boolean; rowIdPrefix: string; workspaceRoot: string | null; @@ -1138,7 +1142,8 @@ function buildTurnSummaryRow({ completedAt, includeNestedRows, rowIdPrefix, - segmentIndex, + rowIdSegmentIndex, + sourceBounds, sourceMessages, sourceRows, startedAt, @@ -1150,13 +1155,13 @@ function buildTurnSummaryRow({ } const bounds = - segmentIndex === null || sourceMessages.length === 0 + sourceBounds === "turn" || sourceMessages.length === 0 ? getTurnBounds(turn) : getTimelineMessageBounds(sourceMessages); const rowId = - segmentIndex === null + rowIdSegmentIndex === null ? `${rowIdPrefix}${turn.threadId}:${turn.turnId}:turn` - : `${rowIdPrefix}${turn.threadId}:${turn.turnId}:turn:${segmentIndex}`; + : `${rowIdPrefix}${turn.threadId}:${turn.turnId}:turn:${rowIdSegmentIndex}`; const resolvedCompletedAt = completedAt ?? getTimelineMessageCompletedAt(sourceMessages); @@ -1209,7 +1214,8 @@ function buildCompletedTurnSummaryRows({ completedAt: item.completedAt, includeNestedRows, rowIdPrefix, - segmentIndex: item.segmentIndex, + rowIdSegmentIndex: item.rowIdSegmentIndex, + sourceBounds: item.sourceBounds, sourceMessages: item.sourceMessages, sourceRows, startedAt: item.startedAt, @@ -1224,6 +1230,7 @@ function buildCompletedTurnSummaryRows({ } function buildTurnRows({ + contextOnlyCompletedTurnIds, includeNestedRows, rowIdPrefix, turn, @@ -1244,7 +1251,10 @@ function buildTurnRows({ } const { summaryItems, terminalMessages, trailingMessages } = - groupCompletedTurnMessages(turn); + groupCompletedTurnMessages( + turn, + contextOnlyCompletedTurnIds?.has(turn.turnId) === true, + ); const terminalRows = terminalMessages.flatMap((message) => convertMessage(message, { includeNestedRows, rowIdPrefix, workspaceRoot }), ); @@ -1365,6 +1375,7 @@ function buildTimelineRows( appendRows( rows, buildTurnRows({ + contextOnlyCompletedTurnIds: options.contextOnlyCompletedTurnIds, turn: entry.turn, includeNestedRows, rowIdPrefix: options.rowIdPrefix, @@ -1400,6 +1411,7 @@ export function buildThreadTimelineFromEvents( const rows = [ ...buildTimelineRows(projection, { + contextOnlyCompletedTurnIds: args.options.contextOnlyCompletedTurnIds, includeNestedRows: args.options.includeNestedRows, rowIdPrefix: ROOT_TIMELINE_ROW_ID_PREFIX, workspaceRoot: args.options.workspaceRoot, @@ -1450,9 +1462,9 @@ export function buildThreadTimelineFromEvents( }; } -export function buildThreadTimelineTurnDetailsFromEvents( +function buildThreadTimelineTurnDetailRows( args: BuildThreadTimelineTurnDetailsFromEventsArgs, -): ThreadTimelineTurnDetailsFromEventsResult { +): TimelineRow[] { const projection = buildEventProjectionEntries(args.events, { includeProviderUnhandledOperations: args.options.includeProviderUnhandledOperations, @@ -1461,11 +1473,17 @@ export function buildThreadTimelineTurnDetailsFromEvents( threadName: args.options.threadName, turnMessageDetail: "full", }); - const nestedRows = buildTimelineRows(projection, { + return buildTimelineRows(projection, { includeNestedRows: true, rowIdPrefix: ROOT_TIMELINE_ROW_ID_PREFIX, workspaceRoot: args.options.workspaceRoot, }); +} + +export function buildThreadTimelineTurnDetailsFromEvents( + args: BuildThreadTimelineTurnDetailsFromEventsArgs, +): ThreadTimelineTurnDetailsFromEventsResult { + const nestedRows = buildThreadTimelineTurnDetailRows(args); const matchingTurnSummary = findMatchingTurnSummaryRow( nestedRows, args.options, @@ -1495,3 +1513,22 @@ export function buildThreadTimelineTurnDetailsFromEvents( rows: nestedRows.filter((row) => !isRootOwnedHumanSteerRow(row)), }; } + +/** + * Projects one server-selected detail page. Unlike exact-range hydration, a + * page need not coincide with the source bounds of a summary row. + */ +export function buildThreadTimelineTurnDetailPageFromEvents( + args: BuildThreadTimelineTurnDetailsFromEventsArgs, +): TimelineRow[] { + const nestedRows = buildThreadTimelineTurnDetailRows(args); + const turnChildren = nestedRows.flatMap((row) => + row.kind === "turn" ? (row.children ?? []) : [], + ); + if (nestedRows.some((row) => row.kind === "turn")) { + // Terminal assistant replies and human steers are root-owned siblings of + // a completed summary, not children of the expanded “Worked for…” row. + return turnChildren; + } + return nestedRows.filter((row) => !isRootOwnedHumanSteerRow(row)); +} diff --git a/packages/thread-view/src/completed-turn-grouping.ts b/packages/thread-view/src/completed-turn-grouping.ts index 175612c810..bf0a80fa03 100644 --- a/packages/thread-view/src/completed-turn-grouping.ts +++ b/packages/thread-view/src/completed-turn-grouping.ts @@ -14,7 +14,8 @@ interface CompletedTurnSummaryGroup { kind: "summary"; startedAt: number; completedAt: number | null; - segmentIndex: number | null; + rowIdSegmentIndex: number | null; + sourceBounds: "messages" | "turn"; sourceMessages: EventProjectionMessage[]; summaryCount: number; } @@ -84,25 +85,48 @@ function getSummaryMessageBounds( return { startedAt }; } -function applySingleSummaryTurnBounds( +function combineSummaryGroupsWithoutLaterHumanBoundary( turn: EventProjectionTurn, items: readonly CompletedTurnSummaryItem[], ): CompletedTurnSummaryItem[] { const summaryGroups = items.filter(isCompletedTurnSummaryGroup); - if (summaryGroups.length !== 1) { + if (summaryGroups.length === 0) { return [...items]; } + const canUseCanonicalIdentity = + (turn.externalUserBoundarySeqs?.length ?? 0) === 0 && + !items.some( + (item) => + item.kind === "ungrouped-message" && + isTimelineUngroupableMessage(item.message) && + item.message.sourceSeqStart > turn.sourceSeqStart, + ); - const onlySummaryGroup = summaryGroups[0]; - return items.map((item) => - item === onlySummaryGroup - ? { - ...item, - startedAt: turn.startedAt, - completedAt: turn.completedAt, - } - : item, - ); + if (!canUseCanonicalIdentity) { + return [...items]; + } + + const firstSummaryGroup = summaryGroups[0]; + if (!firstSummaryGroup) { + return [...items]; + } + const combinedSummaryGroup: CompletedTurnSummaryGroup = { + ...firstSummaryGroup, + startedAt: turn.startedAt, + completedAt: turn.completedAt, + rowIdSegmentIndex: null, + sourceBounds: + summaryGroups.length === 1 ? firstSummaryGroup.sourceBounds : "messages", + sourceMessages: summaryGroups.flatMap((group) => group.sourceMessages), + summaryCount: summaryGroups.reduce( + (count, group) => count + group.summaryCount, + 0, + ), + }; + return items.flatMap((item): CompletedTurnSummaryItem[] => { + if (item === firstSummaryGroup) return [combinedSummaryGroup]; + return isCompletedTurnSummaryGroup(item) ? [] : [item]; + }); } function splitCompletedTurnMessages( @@ -198,7 +222,8 @@ function groupCompletedTurnSummaryMessages( kind: "summary", startedAt: turn.startedAt, completedAt: turn.completedAt, - segmentIndex: null, + rowIdSegmentIndex: null, + sourceBounds: "turn", sourceMessages: summaryMessages, summaryCount: turn.summaryCount, }, @@ -220,7 +245,8 @@ function groupCompletedTurnSummaryMessages( kind: "summary", startedAt: bounds.startedAt, completedAt: null, - segmentIndex, + rowIdSegmentIndex: segmentIndex, + sourceBounds: "messages", sourceMessages, summaryCount: getProjectionSummaryCount(sourceMessages, undefined), }); @@ -295,15 +321,19 @@ function groupCompletedTurnSummaryMessages( externalBoundaryIndex += 1; } flushGroupedMessages(); - return applySingleSummaryTurnBounds(turn, items); + return combineSummaryGroupsWithoutLaterHumanBoundary(turn, items); } export function groupCompletedTurnMessages( turn: EventProjectionTurn, + completionIsContextOnly = false, ): CompletedTurnMessageGroups { const messages = turn.messages ?? []; const { summaryMessages, terminalMessages, trailingMessages } = - splitCompletedTurnMessages(messages, turn.terminalMessage); + splitCompletedTurnMessages( + messages, + completionIsContextOnly ? undefined : turn.terminalMessage, + ); return { summaryItems: unwrapSingletonContextManagementGroups( groupCompletedTurnSummaryMessages( diff --git a/packages/thread-view/src/index.ts b/packages/thread-view/src/index.ts index 748a8fb005..6da4e9f1a6 100644 --- a/packages/thread-view/src/index.ts +++ b/packages/thread-view/src/index.ts @@ -51,6 +51,7 @@ export { export type { FileChangeAction } from "./file-change-summary.js"; export { buildThreadTimelineFromEvents, + buildThreadTimelineTurnDetailPageFromEvents, buildThreadTimelineTurnDetailsFromEvents, } from "./build-thread-timeline.js"; export { extractThreadTimelineActivePlanTurn } from "./active-prompt-mode-extraction.js"; diff --git a/packages/thread-view/src/timeline-noise-events.ts b/packages/thread-view/src/timeline-noise-events.ts index a1a0d864dc..eb6dc88afe 100644 --- a/packages/thread-view/src/timeline-noise-events.ts +++ b/packages/thread-view/src/timeline-noise-events.ts @@ -12,11 +12,16 @@ import type { ThreadEventType } from "@bb/domain"; * `turn/plan/updated` is NOT here: persisted codex plan notifications decode * into `planSteps` items at read time (legacy-thread-events.ts), so a window * must read them to show old threads' plans. + * + * `provider/rateLimits/updated` feeds usage UI outside the timeline and has no + * timeline projection. High-frequency provider updates must not consume a + * completed-turn detail page's event budget. */ export const THREAD_TIMELINE_EXCLUDED_EVENT_TYPES = [ "thread/started", "thread/identity", "thread/contextWindowUsage/updated", "thread/tokenUsage/updated", + "provider/rateLimits/updated", "turn/diff/updated", ] as const satisfies readonly ThreadEventType[]; diff --git a/packages/thread-view/test/completed-turn-grouping.test.ts b/packages/thread-view/test/completed-turn-grouping.test.ts index 19d509f4b6..efb777e1fd 100644 --- a/packages/thread-view/test/completed-turn-grouping.test.ts +++ b/packages/thread-view/test/completed-turn-grouping.test.ts @@ -189,7 +189,8 @@ describe("groupCompletedTurnMessages", () => { kind: "summary", startedAt: 1, completedAt: 2, - segmentIndex: null, + rowIdSegmentIndex: null, + sourceBounds: "turn", summaryCount: 2, }, ]); @@ -225,7 +226,8 @@ describe("groupCompletedTurnMessages", () => { kind: "summary", startedAt: 1, completedAt: 4, - segmentIndex: 0, + rowIdSegmentIndex: null, + sourceBounds: "messages", sourceMessages: [{ id: "narration" }, { id: "command" }], summaryCount: 2, }, @@ -234,6 +236,107 @@ describe("groupCompletedTurnMessages", () => { expect(groups.terminalMessages).toEqual([hookReply]); }); + it("uses the canonical row identity when the accepted request starts the turn", () => { + const seed = userMessage({ id: "seed", seq: 1 }); + const narration = assistantMessage({ id: "narration", seq: 2 }); + const command = commandMessage({ id: "command", seq: 3 }); + const answer = assistantMessage({ id: "answer", seq: 4 }); + const terminal = assistantMessage({ id: "terminal", seq: 5 }); + + const groups = groupCompletedTurnMessages( + completedTurn([seed, narration, command, answer, terminal], terminal), + ); + + expect(groups.summaryItems).toMatchObject([ + { kind: "ungrouped-message", message: { id: "seed" } }, + { + kind: "summary", + rowIdSegmentIndex: null, + sourceBounds: "messages", + sourceMessages: [{ id: "narration" }, { id: "command" }], + }, + { kind: "ungrouped-message", message: { id: "answer" } }, + ]); + }); + + it("combines work around visible assistant replies without a later human boundary", () => { + const firstNarration = assistantMessage({ id: "narration-1", seq: 1 }); + const firstCommand = commandMessage({ id: "command-1", seq: 2 }); + const visibleReply = assistantMessage({ id: "visible-reply", seq: 3 }); + const secondNarration = assistantMessage({ id: "narration-2", seq: 4 }); + const secondCommand = commandMessage({ id: "command-2", seq: 5 }); + const terminal = assistantMessage({ id: "terminal", seq: 6 }); + + const groups = groupCompletedTurnMessages( + completedTurn( + [ + firstNarration, + firstCommand, + visibleReply, + secondNarration, + secondCommand, + terminal, + ], + terminal, + ), + ); + + expect(groups.summaryItems).toMatchObject([ + { + kind: "summary", + rowIdSegmentIndex: null, + sourceMessages: [ + { id: "narration-1" }, + { id: "command-1" }, + { id: "narration-2" }, + { id: "command-2" }, + ], + }, + { kind: "ungrouped-message", message: { id: "visible-reply" } }, + ]); + }); + + it("does not treat a slice-local assistant as terminal when completion is context", () => { + const assistant = assistantMessage({ id: "assistant", seq: 1 }); + const command = commandMessage({ id: "command", seq: 2 }); + const groups = groupCompletedTurnMessages( + completedTurn([assistant, command], assistant), + true, + ); + + expect(groups.summaryItems).toMatchObject([ + { + kind: "summary", + rowIdSegmentIndex: null, + sourceMessages: [{ id: "assistant" }, { id: "command" }], + }, + ]); + expect(groups.terminalMessages).toEqual([]); + expect(groups.trailingMessages).toEqual([]); + }); + + it("keeps a summary segmented after a later human boundary", () => { + const seed = userMessage({ id: "seed", seq: 1 }); + const command = commandMessage({ id: "command", seq: 2 }); + const followUp = userMessage({ id: "follow-up", seq: 3 }); + const terminal = assistantMessage({ id: "terminal", seq: 4 }); + + const groups = groupCompletedTurnMessages( + completedTurn([seed, command, followUp, terminal], terminal), + ); + + expect(groups.summaryItems).toMatchObject([ + { kind: "ungrouped-message", message: { id: "seed" } }, + { + kind: "summary", + rowIdSegmentIndex: 0, + sourceBounds: "messages", + sourceMessages: [{ id: "command" }], + }, + { kind: "ungrouped-message", message: { id: "follow-up" } }, + ]); + }); + it("keeps every response in a run of adjacent assistant texts", () => { const first = assistantMessage({ id: "first", seq: 1 }); const second = assistantMessage({ id: "second", seq: 2 }); @@ -315,7 +418,8 @@ describe("groupCompletedTurnMessages", () => { kind: "summary", startedAt: 1, completedAt: 4, - segmentIndex: null, + rowIdSegmentIndex: null, + sourceBounds: "turn", summaryCount: 4, }, ]); @@ -340,7 +444,8 @@ describe("groupCompletedTurnMessages", () => { kind: "summary", startedAt: 1, completedAt: null, - segmentIndex: 0, + rowIdSegmentIndex: 0, + sourceBounds: "messages", summaryCount: 1, }, { @@ -353,7 +458,8 @@ describe("groupCompletedTurnMessages", () => { kind: "summary", startedAt: 3, completedAt: null, - segmentIndex: 1, + rowIdSegmentIndex: 1, + sourceBounds: "messages", summaryCount: 1, }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2625dbe64..3d799f174a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,6 +429,9 @@ importers: apps/cli: dependencies: + '@bb/client-core': + specifier: workspace:* + version: link:../../packages/client-core '@bb/config': specifier: workspace:* version: link:../../packages/config @@ -572,7 +575,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) wrangler: specifier: ^4.100.0 version: 4.107.0(@cloudflare/workers-types@4.20260702.1) @@ -1634,7 +1637,7 @@ importers: version: typescript@7.0.2 vitest: specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) packages/core-ui: dependencies: From 47081ebfb116683ff33722415cd103c15f23b028 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 14:34:26 -0700 Subject: [PATCH 2/4] Paginate turn details only after exact overflow --- apps/server/src/routes/threads/data.ts | 2 - apps/server/src/services/threads/timeline.ts | 24 ++++- .../threads/timeline-in-turn-window.test.ts | 89 ++++++++++++++++--- packages/db/src/data/events.ts | 61 +++++++------ packages/db/test/data/events.test.ts | 60 +++++++++++++ .../thread-view/src/timeline-noise-events.ts | 5 -- 6 files changed, 185 insertions(+), 56 deletions(-) diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 29fe3439ff..e6b3220349 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -49,7 +49,6 @@ import { buildTimelineTurnSummaryDetails, THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT, THREAD_TIMELINE_SEGMENT_LIMIT_MAX, - THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, } from "../../services/threads/timeline.js"; import type { ThreadTimelinePageKind, @@ -478,7 +477,6 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { return context.json( buildTimelineTurnDetailsPage(deps.db, thread, { ...(query.cursor ? { cursor: query.cursor } : {}), - eventLimit: THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, includeProviderUnhandledOperations, providerDisplayName: resolveThreadProviderDisplayName( deps, diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index 37e8095b28..aa1676b29d 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -167,7 +167,6 @@ interface BuildTimelineTurnSummaryDetailsOptions extends TimelineTurnSummarySele interface BuildTimelineTurnDetailsPageOptions { cursor?: string; - eventLimit: number; includeProviderUnhandledOperations: boolean; providerDisplayName?: string; turnId: string; @@ -182,8 +181,6 @@ export const THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT = 20; export const THREAD_TIMELINE_SEGMENT_LIMIT_MAX = 100; -export const THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT = 250; - /** * Driver rows and decoded events can use several times their stored JSON size. * Bound each page before either representation enters the V8 heap. @@ -2298,11 +2295,30 @@ export function buildTimelineTurnDetailsPage( throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); } + if (options.cursor === undefined) { + try { + const details = buildTimelineTurnSummaryDetailsRange(db, thread, { + includeProviderUnhandledOperations: + options.includeProviderUnhandledOperations, + providerDisplayName: options.providerDisplayName, + resourceKind: "exact-range", + ...bounds, + }); + return { rows: details.rows, nextCursor: null }; + } catch (error) { + if ( + !(error instanceof ApiError) || + error.body.code !== "timeline_window_too_large" + ) { + throw error; + } + } + } + const page = readStoredTimelineWindowForwardPage(db, { beforeSequence: bounds.sourceSeqEnd + 1, excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, maxDataBytes: THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, - maxEventCount: options.eventLimit, maxInlineOutputChars: DEFAULT_MAX_INLINE_OUTPUT_CHARS, sequenceStart: sourceSeqStart, threadId: thread.id, diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index 035b754f0d..739af6e508 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -27,7 +27,6 @@ import { buildTimelineTurnSummaryDetails, buildThreadTimelineWithProfile, THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, - THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, } from "../../../src/services/threads/timeline.js"; /** Larger than any thread these tests build, so the budget never binds. */ @@ -845,6 +844,71 @@ describe("in-turn timeline windows", () => { ); }); + it("returns full outputs when the completed turn fits the exact-range limit", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + completeLastTurn: true, + itemsPerTurn: [1], + outputChars: 50_000, + }); + + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + turnId: "turn-1", + }); + const command = detail.rows.find( + (row) => row.kind === "work" && row.workKind === "command", + ); + + expect(detail.nextCursor).toBeNull(); + expect(command?.kind).toBe("work"); + if (command?.kind !== "work" || command.workKind !== "command") { + throw new Error("expected a command detail row"); + } + expect(command.output).toBe("o".repeat(50_000)); + }); + + it("returns one exact page when more than 250 small events fit", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + completeLastTurn: true, + itemsPerTurn: [200], + }); + + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + turnId: "turn-1", + }); + + expect(detail.nextCursor).toBeNull(); + expect(collectCommandCallIds(detail.rows, new Set())).toBe(200); + }); + + it("returns one preview page when only capped outputs fit", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + completeLastTurn: true, + itemsPerTurn: [125], + outputChars: 40_000, + }); + + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + turnId: "turn-1", + }); + const commandOutputs = detail.rows.flatMap((row) => + row.kind === "work" && row.workKind === "command" ? [row.output] : [], + ); + + expect(detail.nextCursor).toBeNull(); + expect(commandOutputs).toHaveLength(125); + expect( + commandOutputs.every((output) => + output.includes("more characters truncated"), + ), + ).toBe(true); + }); + it("pages through a finished turn that exceeds the event-data byte limit", () => { const { db, thread } = setup(); seedTurns(db, thread, { @@ -854,6 +918,15 @@ describe("in-turn timeline windows", () => { itemsPerTurn: [BYTE_WINDOW_ITEM_COUNT], }); + expect(() => + buildTimelineTurnSummaryDetails(db, thread, { + includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, + turnId: "turn-1", + }), + ).toThrow("Timeline turn details exceed the safe response limit"); + const commandCallIds = new Set(); const turnRowIds = new Set(); let cursor: TimelinePaginationCursor | null = null; @@ -897,7 +970,6 @@ describe("in-turn timeline windows", () => { do { const detail = buildTimelineTurnDetailsPage(db, thread, { ...(detailCursor ? { cursor: detailCursor } : {}), - eventLimit: THREAD_TIMELINE_TURN_DETAIL_EVENT_LIMIT, includeProviderUnhandledOperations: false, turnId: "turn-1", }); @@ -1279,7 +1351,7 @@ describe("timeline segment anchors", () => { }); describe("timeline window event exclusions", () => { - it("never reads non-projecting diff or rate-limit events into a window", () => { + it("never reads workspace diff events into a window", () => { const { db, thread } = setup(); seedTurns(db, thread, { completeLastTurn: true, itemsPerTurn: [5] }); const withoutDiffs = buildPage(db, thread, LARGE_BUDGET, null); @@ -1298,17 +1370,6 @@ describe("timeline window event exclusions", () => { parentToolCallId: null, data: JSON.stringify({ diff: "x".repeat(50_000) }), }, - { - threadId: thread.id, - sequence: 501, - type: "provider/rateLimits/updated", - scope: threadScope(), - providerThreadId, - itemId: null, - itemKind: null, - parentToolCallId: null, - data: JSON.stringify({}), - }, ]); const withDiffs = buildPage(db, thread, LARGE_BUDGET, null); diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index d24dcaffde..5d73c35066 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1238,7 +1238,6 @@ export interface ReadStoredTimelineWindowForwardPageArgs extends ListStoredTimelineWindowEventRowsArgs { beforeSequence: number; maxDataBytes: number; - maxEventCount: number; } export type StoredTimelineWindowForwardPage = @@ -3137,55 +3136,55 @@ export function listStoredTimelineWindowEventRows( .all(); } -/** - * Reads the oldest bounded prefix of a sequence range in one query. Completed - * turn details use this instead of first measuring a range and then reading it. - */ +/** Reads the oldest byte-bounded prefix of a sequence range. */ export function readStoredTimelineWindowForwardPage( db: DbConnection, args: ReadStoredTimelineWindowForwardPageArgs, ): StoredTimelineWindowForwardPage { - const fields = storedEventRowFieldsWithInlineOutputLimit( - args.maxInlineOutputChars, - ); const data = storedTimelineWindowDataColumn(args.maxInlineOutputChars); - const candidates = db + // Walk only sequence + size until the byte boundary is known. Selecting the + // payload here would materialize the first excluded row (which may itself be + // enormous) and would retain every included row while the iterator is open. + const query = db .select({ - ...fields, dataBytes: sql`length(CAST(${data} AS BLOB))`.as("data_bytes"), + sequence: events.sequence, }) .from(events) .where(and(...storedTimelineWindowConditions(args))) .orderBy(events.sequence) - .limit(args.maxEventCount + 1) - .all(); - - const rows: StoredEventRow[] = []; + .toSQL(); + const statement = db.$client.prepare< + unknown[], + { data_bytes: number; sequence: number } + >(query.sql); let dataBytes = 0; - for (const candidate of candidates) { - if ( - rows.length === args.maxEventCount || - dataBytes + candidate.dataBytes > args.maxDataBytes - ) { - if (rows.length === 0) { + let hasRows = false; + let nextSequenceStart: number | null = null; + for (const row of statement.iterate(...query.params)) { + if (dataBytes + row.data_bytes > args.maxDataBytes) { + if (!hasRows) { return { - dataBytes: candidate.dataBytes, + dataBytes: row.data_bytes, kind: "single-event-too-large", - sequence: candidate.sequence, + sequence: row.sequence, }; } - return { - kind: "page", - nextSequenceStart: candidate.sequence, - rows, - }; + nextSequenceStart = row.sequence; + break; } - const { dataBytes: _dataBytes, ...row } = candidate; - rows.push(row); - dataBytes += candidate.dataBytes; + dataBytes += row.data_bytes; + hasRows = true; } - return { kind: "page", nextSequenceStart: null, rows }; + const rows = listStoredTimelineWindowEventRows(db, { + beforeSequence: nextSequenceStart ?? args.beforeSequence, + excludedTypes: args.excludedTypes, + maxInlineOutputChars: args.maxInlineOutputChars, + sequenceStart: args.sequenceStart, + threadId: args.threadId, + }); + return { kind: "page", nextSequenceStart, rows }; } function listLatestRowsForContextWindowUsage( diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 1465b3418a..ee1e065283 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -54,6 +54,7 @@ import { pruneTokenUsageEventsBeforeSequence, pruneResolvedItemDeltas, pruneThreadEventsBeforeSequence, + readStoredTimelineWindowForwardPage, listLatestOpenBackgroundTaskStateRowsForThread, STORED_TIMELINE_BYTE_PREFLIGHT_EVENT_LIMIT, } from "../../src/data/events.js"; @@ -4743,6 +4744,65 @@ describe("timeline read-boundary output truncation", () => { })); }); + it("reads the oldest byte-bounded prefix and resumes at the next row", () => { + const { db, thread } = setup(); + insertEvents( + db, + noopNotifier, + [100, 200, 300].map((messageChars, index) => ({ + threadId: thread.id, + sequence: index + 1, + type: "system/error" as const, + ...threadEventFields, + data: JSON.stringify({ message: "x".repeat(messageChars) }), + })), + ); + const rows = listStoredTimelineWindowEventRows(db, { + beforeSequence: 4, + maxInlineOutputChars: null, + sequenceStart: 1, + threadId: thread.id, + }); + const firstTwoBytes = rows + .slice(0, 2) + .reduce((total, row) => total + Buffer.byteLength(row.data), 0); + + const first = readStoredTimelineWindowForwardPage(db, { + beforeSequence: 4, + maxDataBytes: firstTwoBytes, + maxInlineOutputChars: null, + sequenceStart: 1, + threadId: thread.id, + }); + expect(first).toMatchObject({ + kind: "page", + nextSequenceStart: 3, + rows: [{ sequence: 1 }, { sequence: 2 }], + }); + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 4, + type: "system/error", + ...threadEventFields, + data: JSON.stringify({ message: "write after forward byte cut" }), + }, + ]); + + const second = readStoredTimelineWindowForwardPage(db, { + beforeSequence: 4, + maxDataBytes: firstTwoBytes, + maxInlineOutputChars: null, + sequenceStart: 3, + threadId: thread.id, + }); + expect(second).toMatchObject({ + kind: "page", + nextSequenceStart: null, + rows: [{ sequence: 3 }], + }); + }); + it("bounds the byte-total preflight before using the early-stopping iterator", () => { const { db, thread } = setup(); const validData = JSON.stringify({ message: "valid" }); diff --git a/packages/thread-view/src/timeline-noise-events.ts b/packages/thread-view/src/timeline-noise-events.ts index eb6dc88afe..a1a0d864dc 100644 --- a/packages/thread-view/src/timeline-noise-events.ts +++ b/packages/thread-view/src/timeline-noise-events.ts @@ -12,16 +12,11 @@ import type { ThreadEventType } from "@bb/domain"; * `turn/plan/updated` is NOT here: persisted codex plan notifications decode * into `planSteps` items at read time (legacy-thread-events.ts), so a window * must read them to show old threads' plans. - * - * `provider/rateLimits/updated` feeds usage UI outside the timeline and has no - * timeline projection. High-frequency provider updates must not consume a - * completed-turn detail page's event budget. */ export const THREAD_TIMELINE_EXCLUDED_EVENT_TYPES = [ "thread/started", "thread/identity", "thread/contextWindowUsage/updated", "thread/tokenUsage/updated", - "provider/rateLimits/updated", "turn/diff/updated", ] as const satisfies readonly ThreadEventType[]; From e7438c975a9694ed73c51c933626597067985ce9 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 14:39:45 -0700 Subject: [PATCH 3/4] Bump plugin SDK for turn details API --- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 232bbe94ac..cd0a914b39 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.22"; +export const PLUGIN_SDK_VERSION = "0.4.23"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index ba46313380..f7d2089651 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.22", + "version": "0.4.23", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" From b51ae79c399520bcdbe5f38e22c68e0b8bbe564c Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 21:41:22 -0700 Subject: [PATCH 4/4] Scope paginated turn details to timeline rows --- .../thread/timeline/ThreadTimelineRows.tsx | 9 +++- apps/app/src/hooks/queries/query-keys.ts | 8 +++ .../src/hooks/queries/thread-queries.test.tsx | 9 ++++ apps/app/src/hooks/queries/thread-queries.ts | 2 + .../thread-detail/thread-detail-queries.ts | 2 + apps/mobile/src/lib/query/query-keys.ts | 8 +++ .../thread/timeline/TurnChildrenLoader.tsx | 2 + apps/server/src/routes/threads/data.ts | 2 + apps/server/src/services/threads/timeline.ts | 40 ++++++++------- .../test/public/public-thread-data.test.ts | 2 +- .../threads/timeline-in-turn-window.test.ts | 49 +++++++++++++++++++ packages/sdk/src/areas/threads.ts | 2 + packages/server-contract/src/api/threads.ts | 2 + .../server-contract/test/contract.test.ts | 6 ++- 14 files changed, 122 insertions(+), 21 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index dda7802471..198d678c6f 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -1495,8 +1495,13 @@ function LazyTurnRowBody({ const { getViewRows, threadId } = useTimelineRendererStaticContext(); const { threadId: rowThreadId, turnId: rowTurnId } = row; const identity = useMemo( - () => ({ threadId: threadId ?? rowThreadId, turnId: rowTurnId }), - [rowThreadId, rowTurnId, threadId], + () => ({ + sourceSeqEnd: row.sourceSeqEnd, + sourceSeqStart: row.sourceSeqStart, + threadId: threadId ?? rowThreadId, + turnId: rowTurnId, + }), + [row.sourceSeqEnd, row.sourceSeqStart, rowThreadId, rowTurnId, threadId], ); const { data: detail, diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 4fdccb947e..41e3f4b0dc 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -355,6 +355,8 @@ export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { turnId: string; } export interface ThreadTimelineTurnDetailsQueryIdentity { + sourceSeqEnd: number; + sourceSeqStart: number; threadId: string; turnId: string; } @@ -362,6 +364,8 @@ type ThreadTimelineTurnDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, string, + number, + number, "pages", ]; type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [ @@ -943,6 +947,8 @@ export function threadTimelineTurnSummaryDetailsQueryKey({ } export function threadTimelineTurnDetailsQueryKey({ + sourceSeqEnd, + sourceSeqStart, threadId, turnId, }: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey { @@ -950,6 +956,8 @@ export function threadTimelineTurnDetailsQueryKey({ THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, threadId, turnId, + sourceSeqStart, + sourceSeqEnd, "pages", ]; } diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index d4c3f304b4..7cc8f76093 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -212,6 +212,8 @@ describe("useThreadTimelineTurnDetails", () => { const result = renderHook( () => useThreadTimelineTurnDetails({ + sourceSeqEnd: 2, + sourceSeqStart: 1, threadId: "thread-1", turnId: "turn-1", }), @@ -220,6 +222,13 @@ describe("useThreadTimelineTurnDetails", () => { await waitFor(() => expect(result.result.current.isSuccess).toBe(true)); expect(sdk.threads.timelineTurnDetails).toHaveBeenCalledTimes(1); + expect(sdk.threads.timelineTurnDetails).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sourceSeqEnd: "2", + sourceSeqStart: "1", + }), + ); expect( result.result.current.data?.pages.flatMap((page) => page.rows), ).toHaveLength(1); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 96c8aba038..fd7c04650f 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1082,6 +1082,8 @@ export function useThreadTimelineTurnDetails( identity.threadId, "useThreadTimelineTurnDetails", ), + sourceSeqEnd: String(identity.sourceSeqEnd), + sourceSeqStart: String(identity.sourceSeqStart), turnId: identity.turnId, }), initialPageParam: null as string | null, diff --git a/apps/mobile/src/data/thread-detail/thread-detail-queries.ts b/apps/mobile/src/data/thread-detail/thread-detail-queries.ts index 98875f1c7d..a5496b60c4 100644 --- a/apps/mobile/src/data/thread-detail/thread-detail-queries.ts +++ b/apps/mobile/src/data/thread-detail/thread-detail-queries.ts @@ -240,6 +240,8 @@ export function useTimelineTurnDetails( hookName: "useTimelineTurnDetails", argName: "thread id", }), + sourceSeqEnd: String(identity.sourceSeqEnd), + sourceSeqStart: String(identity.sourceSeqStart), turnId: identity.turnId, signal, }), diff --git a/apps/mobile/src/lib/query/query-keys.ts b/apps/mobile/src/lib/query/query-keys.ts index e0203b3dbf..41eaadada4 100644 --- a/apps/mobile/src/lib/query/query-keys.ts +++ b/apps/mobile/src/lib/query/query-keys.ts @@ -144,6 +144,8 @@ type ThreadTimelineQueryKey = readonly [ string, ]; export interface ThreadTimelineTurnDetailsQueryIdentity { + sourceSeqEnd: number; + sourceSeqStart: number; threadId: string; turnId: string; } @@ -151,6 +153,8 @@ type ThreadTimelineTurnDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, string, + number, + number, "pages", ]; type ThreadTimelineTurnSummaryDetailsQueryKeyPrefix = readonly [ @@ -383,6 +387,8 @@ export function threadTimelineQueryKey( } export function threadTimelineTurnDetailsQueryKey({ + sourceSeqEnd, + sourceSeqStart, threadId, turnId, }: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey { @@ -390,6 +396,8 @@ export function threadTimelineTurnDetailsQueryKey({ THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, threadId, turnId, + sourceSeqStart, + sourceSeqEnd, "pages", ]; } diff --git a/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx b/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx index 099f3601ca..09aa56223f 100644 --- a/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx +++ b/apps/mobile/src/screens/thread/timeline/TurnChildrenLoader.tsx @@ -126,6 +126,8 @@ export function renderTurnChildrenLoaders( key={item.key} itemKey={item.key} identity={{ + sourceSeqEnd: row.sourceSeqEnd, + sourceSeqStart: row.sourceSeqStart, threadId: threadId || row.threadId, turnId: row.turnId, }} diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index e6b3220349..0ff0d288f7 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -482,6 +482,8 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { deps, thread.providerId, ), + sourceSeqStart: parseInteger(query.sourceSeqStart, "sourceSeqStart"), + sourceSeqEnd: parseInteger(query.sourceSeqEnd, "sourceSeqEnd"), turnId: query.turnId, }), ); diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index aa1676b29d..8367e4d352 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -165,11 +165,10 @@ interface BuildTimelineTurnSummaryDetailsOptions extends TimelineTurnSummarySele providerDisplayName?: string; } -interface BuildTimelineTurnDetailsPageOptions { +interface BuildTimelineTurnDetailsPageOptions extends TimelineTurnSummarySelection { cursor?: string; includeProviderUnhandledOperations: boolean; providerDisplayName?: string; - turnId: string; } interface BuildTimelineTurnSummaryDetailsRangeOptions extends BuildTimelineTurnSummaryDetailsOptions { @@ -2204,6 +2203,8 @@ export function buildTimelineTurnSummaryDetails( interface TurnDetailsCursorPayload { sequenceStart: number; + sourceSeqEnd: number; + sourceSeqStart: number; threadId: string; turnId: string; version: 1; @@ -2211,6 +2212,8 @@ interface TurnDetailsCursorPayload { const turnDetailsCursorPayloadSchema = z.object({ sequenceStart: z.number().int().nonnegative(), + sourceSeqEnd: z.number().int().nonnegative(), + sourceSeqStart: z.number().int().nonnegative(), threadId: z.string().min(1), turnId: z.string().min(1), version: z.literal(1), @@ -2235,7 +2238,9 @@ function parseTurnDetailsCursor( !parsed.success || parsed.data.version !== expected.version || parsed.data.threadId !== expected.threadId || - parsed.data.turnId !== expected.turnId + parsed.data.turnId !== expected.turnId || + parsed.data.sourceSeqStart !== expected.sourceSeqStart || + parsed.data.sourceSeqEnd !== expected.sourceSeqEnd ) { throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); } @@ -2245,29 +2250,32 @@ function parseTurnDetailsCursor( function resolveCompletedTurnDetailBounds( db: DbConnection, threadId: string, - turnId: string, + selection: TimelineTurnSummarySelection, ): TimelineTurnSummarySelection { const started = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { sequenceCutoff: Number.MAX_SAFE_INTEGER, threadId, - turnIds: [turnId], + turnIds: [selection.turnId], })[0]; const completed = listStoredTurnCompletedRowsByTurnIds(db, { threadId, - turnIds: [turnId], + turnIds: [selection.turnId], }).at(-1); if (!started || !completed || started.sequence > completed.sequence) { throw new ApiError( 400, "invalid_request", - `Cannot paginate details for incomplete turn ${turnId}`, + `Cannot paginate details for incomplete turn ${selection.turnId}`, ); } - return { - sourceSeqEnd: completed.sequence, - sourceSeqStart: started.sequence, - turnId, - }; + if (selection.sourceSeqStart > selection.sourceSeqEnd) { + throw new ApiError( + 400, + "invalid_request", + `Invalid detail range for completed turn ${selection.turnId}`, + ); + } + return selection; } export function buildTimelineTurnDetailsPage( @@ -2275,12 +2283,10 @@ export function buildTimelineTurnDetailsPage( thread: Thread, options: BuildTimelineTurnDetailsPageOptions, ): TimelineTurnDetailsResponse { - const bounds = resolveCompletedTurnDetailBounds( - db, - thread.id, - options.turnId, - ); + const bounds = resolveCompletedTurnDetailBounds(db, thread.id, options); const cursorIdentity = { + sourceSeqEnd: bounds.sourceSeqEnd, + sourceSeqStart: bounds.sourceSeqStart, threadId: thread.id, turnId: options.turnId, version: 1 as const, diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 508de9a490..83817f94a1 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -1081,7 +1081,7 @@ describe("public thread data routes", () => { } const pageResponse = await harness.app.request( - `/api/v1/threads/${thread.id}/timeline/turn-details?turnId=${turnRow.turnId}`, + `/api/v1/threads/${thread.id}/timeline/turn-details?turnId=${turnRow.turnId}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`, ); expect(pageResponse.status).toBe(200); const page = timelineTurnDetailsResponseSchema.parse( diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index 739af6e508..76f7a8f793 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -854,6 +854,8 @@ describe("in-turn timeline windows", () => { const detail = buildTimelineTurnDetailsPage(db, thread, { includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, turnId: "turn-1", }); const command = detail.rows.find( @@ -877,6 +879,8 @@ describe("in-turn timeline windows", () => { const detail = buildTimelineTurnDetailsPage(db, thread, { includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, turnId: "turn-1", }); @@ -894,6 +898,8 @@ describe("in-turn timeline windows", () => { const detail = buildTimelineTurnDetailsPage(db, thread, { includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, turnId: "turn-1", }); const commandOutputs = detail.rows.flatMap((row) => @@ -909,6 +915,34 @@ describe("in-turn timeline windows", () => { ).toBe(true); }); + it("keeps paginated detail requests scoped to the requested work range", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + completeLastTurn: true, + itemsPerTurn: [3], + }); + + const expandedCommandGroups = [ + { sourceSeqStart: 4, sourceSeqEnd: 5 }, + { sourceSeqStart: 6, sourceSeqEnd: 9 }, + ].map((range) => { + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + ...range, + turnId: "turn-1", + }); + expect(detail.nextCursor).toBeNull(); + const commandIds = new Set(); + collectCommandCallIds(detail.rows, commandIds); + return [...commandIds]; + }); + + expect(expandedCommandGroups).toEqual([ + ["turn-1-item-0"], + ["turn-1-item-1", "turn-1-item-2"], + ]); + }); + it("pages through a finished turn that exceeds the event-data byte limit", () => { const { db, thread } = setup(); seedTurns(db, thread, { @@ -966,11 +1000,14 @@ describe("in-turn timeline windows", () => { const expandedCommandCallIds = new Set(); let expandedCommandRowCount = 0; let detailCursor: string | undefined; + let firstDetailCursor: string | undefined; let detailPages = 0; do { const detail = buildTimelineTurnDetailsPage(db, thread, { ...(detailCursor ? { cursor: detailCursor } : {}), includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, turnId: "turn-1", }); detailPages += 1; @@ -979,12 +1016,24 @@ describe("in-turn timeline windows", () => { expandedCommandCallIds, ); detailCursor = detail.nextCursor ?? undefined; + firstDetailCursor ??= detailCursor; expect(detailPages).toBeLessThan(10); } while (detailCursor); expect(detailPages).toBeGreaterThan(1); expect(expandedCommandRowCount).toBe(BYTE_WINDOW_ITEM_COUNT); expect(expandedCommandCallIds.size).toBe(BYTE_WINDOW_ITEM_COUNT); + expect(firstDetailCursor).toBeDefined(); + if (!firstDetailCursor) throw new Error("expected a detail cursor"); + expect(() => + buildTimelineTurnDetailsPage(db, thread, { + cursor: firstDetailCursor, + includeProviderUnhandledOperations: false, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 3, + turnId: "turn-1", + }), + ).toThrow("Invalid turn details cursor"); }, 15_000); it("keeps latest byte-page row identities stable while a turn grows", () => { diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 4d83971bea..bec7bc839a 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -1131,6 +1131,8 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { param: { id: input.threadId }, query: { turnId: input.turnId, + sourceSeqStart: input.sourceSeqStart, + sourceSeqEnd: input.sourceSeqEnd, ...(input.cursor ? { cursor: input.cursor } : {}), }, }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 215b221dde..3b27d876b3 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -739,6 +739,8 @@ export type TimelineTurnSummaryDetailsQuery = z.infer< export const timelineTurnDetailsQuerySchema = z.object({ turnId: z.string().min(1), + sourceSeqStart: z.string().regex(/^\d+$/), + sourceSeqEnd: z.string().regex(/^\d+$/), cursor: z.string().min(1).optional(), }); export type TimelineTurnDetailsQuery = z.infer< diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 64215618c6..bb3586f71a 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -1624,7 +1624,11 @@ describe("server-contract clients", () => { expect( publicClient.threads[":id"].timeline["turn-details"].$url({ param: { id: "thr_123" }, - query: { turnId: "turn_123" }, + query: { + turnId: "turn_123", + sourceSeqStart: "1", + sourceSeqEnd: "2", + }, }).pathname, ).toBe("/api/v1/threads/thr_123/timeline/turn-details"); expect(