diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index f6fbb5b7a2..dc6d88244b 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, @@ -1516,35 +1494,42 @@ function LazyTurnRowBody({ }: LazyTurnRowBodyProps) { const { getViewRows, threadId } = useTimelineRendererStaticContext(); const { - sourceSeqEnd: rowSourceSeqEnd, - sourceSeqStart: rowSourceSeqStart, + sourceSeqEnd, + sourceSeqStart, threadId: rowThreadId, turnId: rowTurnId, } = row; - const identity = useMemo( - () => - buildTurnSummaryDetailsIdentity({ - rowSourceSeqEnd, - rowSourceSeqStart, - rowThreadId, - rowTurnId, - threadId, - }), - [rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId], + const identity = useMemo( + () => ({ + sourceSeqEnd, + sourceSeqStart, + threadId: threadId ?? rowThreadId, + turnId: rowTurnId, + }), + [rowThreadId, rowTurnId, sourceSeqEnd, sourceSeqStart, 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 +1551,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..41e3f4b0dc 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -354,6 +354,20 @@ export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { threadId: string; turnId: string; } +export interface ThreadTimelineTurnDetailsQueryIdentity { + sourceSeqEnd: number; + sourceSeqStart: number; + threadId: string; + turnId: string; +} +type ThreadTimelineTurnDetailsQueryKey = readonly [ + typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, + string, + string, + number, + number, + "pages", +]; type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [ typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, string, @@ -932,6 +946,22 @@ export function threadTimelineTurnSummaryDetailsQueryKey({ ]; } +export function threadTimelineTurnDetailsQueryKey({ + sourceSeqEnd, + sourceSeqStart, + threadId, + turnId, +}: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey { + return [ + THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY, + threadId, + turnId, + sourceSeqStart, + sourceSeqEnd, + "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..a96f06e4d2 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,70 @@ 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({ + sourceSeqEnd: 2, + sourceSeqStart: 1, + 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", + sourceSeqEnd: "2", + sourceSeqStart: "1", + }), + ); + 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..fd7c04650f 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,36 @@ 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", + ), + sourceSeqEnd: String(identity.sourceSeqEnd), + sourceSeqStart: String(identity.sourceSeqStart), + 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..8e0d4b135d 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,28 +222,22 @@ 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 the row's semantic source range. + * 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), @@ -248,11 +245,9 @@ export function useTimelineTurnSummaryDetails( 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..41eaadada4 100644 --- a/apps/mobile/src/lib/query/query-keys.ts +++ b/apps/mobile/src/lib/query/query-keys.ts @@ -143,19 +143,19 @@ type ThreadTimelineQueryKey = readonly [ typeof THREAD_TIMELINE_QUERY_KEY, string, ]; -/** Identity of one lazily loaded completed-turn detail window. */ -export interface ThreadTimelineTurnSummaryDetailsQueryIdentity { +export interface ThreadTimelineTurnDetailsQueryIdentity { sourceSeqEnd: number; sourceSeqStart: number; 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 +386,19 @@ export function threadTimelineQueryKey( return [THREAD_TIMELINE_QUERY_KEY, threadId]; } -export function threadTimelineTurnSummaryDetailsQueryKey({ +export function threadTimelineTurnDetailsQueryKey({ sourceSeqEnd, sourceSeqStart, 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..09aa56223f 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; } 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 eaa142d8bd..84e40e8460 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({ @@ -53,6 +53,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..0ff0d288f7 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -45,6 +45,7 @@ import { toThreadQueuedMessage } from "../../services/threads/thread-queued-mess import { buildThreadConversationOutline, buildThreadTimelineWithProfile, + buildTimelineTurnDetailsPage, buildTimelineTurnSummaryDetails, THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT, THREAD_TIMELINE_SEGMENT_LIMIT_MAX, @@ -468,6 +469,26 @@ 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 } : {}), + includeProviderUnhandledOperations, + providerDisplayName: resolveThreadProviderDisplayName( + deps, + thread.providerId, + ), + sourceSeqStart: parseInteger(query.sourceSeqStart, "sourceSeqStart"), + sourceSeqEnd: parseInteger(query.sourceSeqEnd, "sourceSeqEnd"), + 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 9208da83b0..8e54ed1289 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..0fc1c88ac9 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,10 +21,12 @@ import type { ThreadConversationOutlineAttachmentSummary, TimelineRow, TimelineSystemRow, + TimelineTurnDetailsResponse, ThreadTimelineResponse, TimelineTurnSummaryDetailsResponse, } from "@bb/server-contract"; import { + findStoredTurnAssistantMessageContextRows, findStoredTimelineWindowByteBudgetFloor, findTimelineWindowBudgetFloorSequence, getStoredEventRowsByParentToolCallIdsDataBytes, @@ -33,6 +36,7 @@ import { getTimelineSegmentAnchorAtSequence, listContextWindowUsageRows, listRecentStoredEventRows, + readStoredTimelineWindowForwardPage, listStoredConversationOutlineEventRows, listStoredClientTurnRequestIdsInRange, listStoredEventRowsByParentToolCallIds, @@ -53,6 +57,7 @@ import { listTimelineSegmentAnchorsDescending, scopedItemRefKey, } from "@bb/db"; +import { z } from "zod"; import type { DbConnection, InlineOutputCharLimit, @@ -161,6 +166,18 @@ interface BuildTimelineTurnSummaryDetailsOptions extends TimelineTurnSummarySele providerDisplayName?: string; } +interface BuildTimelineTurnDetailsPageOptions extends TimelineTurnSummarySelection { + cursor?: string; + includeProviderUnhandledOperations: boolean; + providerDisplayName?: string; +} + +interface BuildTimelineTurnSummaryDetailsRangeOptions extends BuildTimelineTurnSummaryDetailsOptions { + preloadedEventRows?: readonly StoredEventRow[]; + resourceKind: "legacy-exact-range" | "logical-exact-range" | "page"; + semanticSourceSeqStart?: number; +} + export const THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT = 20; export const THREAD_TIMELINE_SEGMENT_LIMIT_MAX = 100; @@ -808,6 +825,11 @@ interface SequenceWindowItemRowsArgs extends TimelineWindowRowsArgs { beforeSequence: number | undefined; /** See {@link InlineOutputCharLimit}. */ maxInlineOutputChars: InlineOutputCharLimit; + /** + * Earliest sequence whose items belong to this semantic resource. Items + * starting before it are neighboring-group context, not page-owned items. + */ + itemOwnershipSequenceStart?: number; /** Inclusive lower bound of the window. */ sequenceStart: number; } @@ -875,6 +897,7 @@ function ensureSequenceWindowWholeItemRows( threadId: args.threadId, }); const itemKeysOwnedByNewerWindow = new Set(); + const itemKeysOwnedBeforeSemanticSelection = new Set(); const itemsStartingBeforeWindow = new Map(); for (const span of spans) { const key = scopedItemRefKey(span); @@ -885,6 +908,13 @@ function ensureSequenceWindowWholeItemRows( itemKeysOwnedByNewerWindow.add(key); continue; } + if ( + args.itemOwnershipSequenceStart !== undefined && + span.minSequence < args.itemOwnershipSequenceStart + ) { + itemKeysOwnedBeforeSemanticSelection.add(key); + continue; + } if (span.minSequence < args.sequenceStart) { itemsStartingBeforeWindow.set(key, { itemId: span.itemId, @@ -897,9 +927,12 @@ function ensureSequenceWindowWholeItemRows( const rows = args.rows.filter( (row) => row.itemId === null || - !itemKeysOwnedByNewerWindow.has( + (!itemKeysOwnedByNewerWindow.has( scopedItemRefKey(storedEventRowItemRef(row)), - ), + ) && + !itemKeysOwnedBeforeSemanticSelection.has( + scopedItemRefKey(storedEventRowItemRef(row)), + )), ); if (itemsStartingBeforeWindow.size === 0) { return rows; @@ -1566,6 +1599,9 @@ function buildSequencePageTimelineRows( return [ { ...row, + // Every transport fragment keeps a page-local identity. The client + // coalesces only adjacent completed-turn fragments at a page seam; + // visible conversation rows therefore remain semantic boundaries. id: `${row.id}${suffix}`, sourceSeqEnd, sourceSeqStart, @@ -1703,7 +1739,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 +1996,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( @@ -1962,37 +2011,47 @@ export function buildTimelineTurnSummaryDetails( const includeProviderUnhandledOperations = options.includeProviderUnhandledOperations; + const useSemanticSelectionContext = + options.resourceKind !== "legacy-exact-range"; const detailsWindow = { beforeSequence: options.sourceSeqEnd + 1, excludedTypes: THREAD_TIMELINE_EXCLUDED_EVENT_TYPES, 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, @@ -2067,6 +2126,15 @@ export function buildTimelineTurnSummaryDetails( }, useExactEventRowBounds: exactEventRowsForRequestedTurn.removedRows, }); + const assistantContextRows = useSemanticSelectionContext + ? findStoredTurnAssistantMessageContextRows(db, { + afterSequence: sourceRange.sourceSeqEnd, + beforeSequence: sourceRange.sourceSeqStart, + maxInlineOutputChars: DEFAULT_MAX_INLINE_OUTPUT_CHARS, + threadId: thread.id, + turnId: options.turnId, + }) + : { after: null, before: null }; // The same whole-item ownership rule the timeline window applies, for the // same reason. A byte cut can fall between an item's `item/started` and its // `item/completed`, and the timeline gives such an item to the newest slice. @@ -2074,18 +2142,27 @@ export function buildTimelineTurnSummaryDetails( // `item/started` row alone and render it "pending" after the turn finished. const wholeItemEventRows = ensureSequenceWindowWholeItemRows(db, { beforeSequence: detailsWindow.beforeSequence, + itemOwnershipSequenceStart: + options.resourceKind === "legacy-exact-range" + ? undefined + : (options.semanticSourceSeqStart ?? sourceRange.sourceSeqStart), maxInlineOutputChars: detailsInlineOutputLimit, rows: mergeStoredEventRowsById([...requestedTurnStartedRows, ...eventRows]), sequenceStart: detailsWindow.sequenceStart, threadId: thread.id, }); + const eventRowsWithSemanticContext = mergeStoredEventRowsById([ + ...(assistantContextRows.before ? [assistantContextRows.before] : []), + ...wholeItemEventRows, + ...(assistantContextRows.after ? [assistantContextRows.after] : []), + ]); // The floor queries measured the slice before closure, and closure backfills // the earlier lifecycle rows of the items this slice owns. Measure what the // route actually holds, so the parent expansion spends what is left rather // than a pre-closure estimate of it. The subtraction may go negative, which // is the safe direction: the parent fetch then stays inside its bounds. const detailsEventDataBytes = - byteLengthOfStoredEventRows(wholeItemEventRows); + byteLengthOfStoredEventRows(eventRowsWithSemanticContext); const eventRowsWithParentedChildren = ensureTimelineWindowParentedRows(db, { maxInlineOutputChars: detailsInlineOutputLimit, outOfBoundsChildDataByteLimit: @@ -2095,29 +2172,55 @@ export function buildTimelineTurnSummaryDetails( sequenceStart: detailsWindow.sequenceStart, }, threadId: thread.id, - rows: wholeItemEventRows, + rows: eventRowsWithSemanticContext, }).rows; const eventRowsWithTurnStarts = ensureTimelineWindowTurnStartedRows(db, { threadId: thread.id, rows: eventRowsWithParentedChildren, }); + const eventRowsWithTurnLifecycle = useSemanticSelectionContext + ? ensureSequenceWindowTurnCompletedRows(db, { + threadId: thread.id, + rows: eventRowsWithTurnStarts, + }) + : eventRowsWithTurnStarts; const eventRowsWithBackgroundTaskState = ensureTimelineWindowBackgroundTaskStateRows(db, { threadId: thread.id, - rows: eventRowsWithTurnStarts, + rows: eventRowsWithTurnLifecycle, }); - const projectionSourceSeqStart = eventRowsWithTurnStarts.reduce( - (sourceSeqStart, row) => - row.type === "turn/started" && row.turnId === options.turnId - ? Math.min(sourceSeqStart, row.sequence) - : sourceSeqStart, - sourceRange.sourceSeqStart, + const contextOnlyMessageSeqs = new Set( + [assistantContextRows.before, assistantContextRows.after].flatMap((row) => + row ? [row.sequence] : [], + ), ); - const children = buildThreadTimelineTurnDetailsFromEvents({ + const projectionSourceSeqStart = useSemanticSelectionContext + ? sourceRange.sourceSeqStart + : eventRowsWithTurnStarts.reduce( + (sourceSeqStart, row) => + row.type === "turn/started" && row.turnId === options.turnId + ? Math.min(sourceSeqStart, row.sequence) + : sourceSeqStart, + sourceRange.sourceSeqStart, + ); + const projectionArgs = { events: eventRowsWithBackgroundTaskState.map((row) => toThreadEventWithMeta(row), ), options: { + allowContextExpandedMatch: useSemanticSelectionContext, + contextOnlyCompletedTurnIds: + useSemanticSelectionContext && + eventRowsWithTurnLifecycle.some( + (row) => + row.type === "turn/completed" && + row.turnId === options.turnId && + row.sequence > sourceRange.sourceSeqEnd, + ) + ? new Set([options.turnId]) + : undefined, + contextOnlyMessageSeqs: + contextOnlyMessageSeqs.size === 0 ? undefined : contextOnlyMessageSeqs, includeProviderUnhandledOperations, sourceSeqEnd: sourceRange.sourceSeqEnd, sourceSeqStart: projectionSourceSeqStart, @@ -2126,7 +2229,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 +2249,180 @@ 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: "legacy-exact-range", + }); +} + +interface TurnDetailsCursorPayload { + sequenceStart: number; + sourceSeqEnd: number; + sourceSeqStart: number; + threadId: string; + turnId: string; + version: 1; +} + +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), +}); + +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 || + parsed.data.sourceSeqStart !== expected.sourceSeqStart || + parsed.data.sourceSeqEnd !== expected.sourceSeqEnd + ) { + throw new ApiError(400, "invalid_request", "Invalid turn details cursor"); + } + return parsed.data.sequenceStart; +} + +function resolveCompletedTurnDetailBounds( + db: DbConnection, + threadId: string, + selection: TimelineTurnSummarySelection, +): TimelineTurnSummarySelection { + const started = listStoredTurnStartedRowsByTurnIdsUpToSequence(db, { + sequenceCutoff: Number.MAX_SAFE_INTEGER, + threadId, + turnIds: [selection.turnId], + })[0]; + const completed = listStoredTurnCompletedRowsByTurnIds(db, { + threadId, + turnIds: [selection.turnId], + }).at(-1); + if (!started || !completed || started.sequence > completed.sequence) { + throw new ApiError( + 400, + "invalid_request", + `Cannot paginate details for incomplete turn ${selection.turnId}`, + ); + } + if ( + selection.sourceSeqStart > selection.sourceSeqEnd || + selection.sourceSeqStart < started.sequence || + selection.sourceSeqEnd > completed.sequence + ) { + throw new ApiError( + 400, + "invalid_request", + `Invalid detail range for completed turn ${selection.turnId}`, + ); + } + return selection; +} + +export function buildTimelineTurnDetailsPage( + db: DbConnection, + thread: Thread, + options: BuildTimelineTurnDetailsPageOptions, +): TimelineTurnDetailsResponse { + 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, + }; + 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"); + } + + if (options.cursor === undefined) { + try { + const details = buildTimelineTurnSummaryDetailsRange(db, thread, { + includeProviderUnhandledOperations: + options.includeProviderUnhandledOperations, + providerDisplayName: options.providerDisplayName, + resourceKind: "logical-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, + 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", + semanticSourceSeqStart: bounds.sourceSeqStart, + 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..83817f94a1 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}&sourceSeqStart=${turnRow.sourceSeqStart}&sourceSeqEnd=${turnRow.sourceSeqEnd}`, + ); + 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..67d4e68ed8 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,6 +23,7 @@ import type { } from "@bb/server-contract"; import { buildThreadTimeline, + buildTimelineTurnDetailsPage, buildTimelineTurnSummaryDetails, buildThreadTimelineWithProfile, THREAD_TIMELINE_EVENT_DATA_BYTE_LIMIT, @@ -85,6 +86,12 @@ function backgroundTaskData(status: "pending" | "completed"): string { } interface SeedOptions { + /** Emit an assistant message before this item in the last turn. */ + assistantBeforeItem?: number; + /** Emit an assistant response while deferred items are still running. */ + assistantBeforeDeferredCompletion?: boolean; + /** Emit a second, consecutive assistant narration before this item. */ + assistantNarrationBeforeItem?: 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. @@ -97,6 +104,8 @@ interface SeedOptions { delegateLastTurn?: boolean; /** Emit `turn/completed` for the last turn. */ completeLastTurn: boolean; + /** Emit the last turn's terminal assistant response. */ + finalAssistant?: boolean; /** Character count for each seeded command, when testing byte limits. */ commandChars?: number; /** @@ -224,6 +233,42 @@ 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.", + }, + }), + }); + } + if (isLastTurn && options.assistantNarrationBeforeItem === item) { + const itemId = `${turnId}-narration`; + push({ + type: "item/completed", + scope: turnScope(turnId), + providerThreadId, + itemId, + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: itemId, + text: "Continuing with more work.", + }, + }), + }); + } const itemId = `${turnId}-item-${item}`; const command = options.commandChars === undefined @@ -299,6 +344,24 @@ function seedTurns( }), }); } + if (isLastTurn && options.assistantBeforeDeferredCompletion) { + const itemId = `${turnId}-deferred-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 while work is running.", + }, + }), + }); + } for (const item of deferred) { const itemId = `${turnId}-item-${item}`; push({ @@ -351,6 +414,25 @@ function seedTurns( }); } + if (isLastTurn && options.finalAssistant) { + const itemId = `${turnId}-final`; + push({ + type: "item/completed", + scope: turnScope(turnId), + providerThreadId, + itemId, + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ + item: { + type: "agentMessage", + id: itemId, + text: "Finished.", + }, + }), + }); + } + if (!isLastTurn || options.completeLastTurn) { push({ type: "turn/completed", @@ -463,18 +545,36 @@ 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; +} + +function collectAssistantTexts(rows: readonly TimelineRow[]): string[] { + return rows.flatMap((row): string[] => { + if (row.kind === "conversation" && row.role === "assistant") { + return [row.text]; + } + if (row.kind === "work" && row.workKind === "delegation") { + return collectAssistantTexts(row.childRows); + } + if (row.kind === "turn" && row.children !== null) { + return collectAssistantTexts(row.children); + } + return []; + }); } interface WalkResult { @@ -820,43 +920,212 @@ 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, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, + 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, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, + 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, + sourceSeqEnd: getLatestThreadSequence(db, { threadId: thread.id }), + sourceSeqStart: 2, + 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("keeps row expansion inside work segments split by visible assistant replies", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + assistantBeforeItem: 1, + assistantNarrationBeforeItem: 1, + completeLastTurn: true, + finalAssistant: true, + itemsPerTurn: [3], + longRunningItemIndexes: [0], + }); + + const timelineRows = buildPage(db, thread, LARGE_BUDGET, null).response + .rows; + expect( + timelineRows.map((row) => + row.kind === "conversation" ? `${row.kind}:${row.role}` : row.kind, + ), + ).toEqual([ + "conversation:user", + "turn", + "conversation:assistant", + "turn", + "conversation:assistant", + ]); + + const turnRows = timelineRows.filter((row) => row.kind === "turn"); + expect(turnRows).toHaveLength(2); + const detailGroups = turnRows.map((row) => { + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + sourceSeqEnd: row.sourceSeqEnd, + sourceSeqStart: row.sourceSeqStart, + turnId: row.turnId, + }); + expect(detail.nextCursor).toBeNull(); + const ids = new Set(); + collectCommandCallIds(detail.rows, ids); + return { + assistantTexts: detail.rows.flatMap((detailRow) => + detailRow.kind === "conversation" && detailRow.role === "assistant" + ? [detailRow.text] + : [], + ), + commandIds: [...ids], + }; + }); + + expect(detailGroups).toEqual([ + { assistantTexts: [], commandIds: ["turn-1-item-0"] }, + { + assistantTexts: ["Continuing with more work."], + commandIds: ["turn-1-item-1", "turn-1-item-2"], + }, + ]); + }); + + it("uses following assistant context beyond an overlapping work range", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { + assistantBeforeDeferredCompletion: true, + completeLastTurn: true, + finalAssistant: true, + itemsPerTurn: [1], + longRunningItemIndexes: [0], + }); + + const timelineRows = buildPage(db, thread, LARGE_BUDGET, null).response + .rows; + expect( + timelineRows.map((row) => + row.kind === "conversation" ? `${row.kind}:${row.role}` : row.kind, + ), + ).toEqual([ + "conversation:user", + "turn", + "conversation:assistant", + "conversation:assistant", + ]); + + const turnRow = timelineRows.find((row) => row.kind === "turn"); + expect(turnRow).toBeDefined(); + if (!turnRow || turnRow.kind !== "turn") return; + + const detail = buildTimelineTurnDetailsPage(db, thread, { + includeProviderUnhandledOperations: false, + sourceSeqEnd: turnRow.sourceSeqEnd, + sourceSeqStart: turnRow.sourceSeqStart, + turnId: turnRow.turnId, + }); + const assistantTexts = collectAssistantTexts(detail.rows); + expect(assistantTexts).toEqual([]); + const commandIds = new Set(); + collectCommandCallIds(detail.rows, commandIds); + expect([...commandIds]).toEqual(["turn-1-item-0"]); + }); + 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], }); + 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 expandedCommandCallIds = new Set(); const turnRowIds = new Set(); + let turnRowCount = 0; let cursor: TimelinePaginationCursor | null = null; let pages = 0; for (;;) { 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; } + turnRowCount += 1; 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 +1141,32 @@ describe("in-turn timeline windows", () => { expect(pages).toBeGreaterThan(2); expect(commandCallIds.size).toBe(BYTE_WINDOW_ITEM_COUNT); + expect(turnRowIds.size).toBe(turnRowCount); + + const expandedCommandCallIds = new Set(); + let expandedCommandRowCount = 0; + let detailCursor: 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; + 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", () => { diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts index 97d6bfcd95..3dc35d82a4 100644 --- a/packages/client-core/src/timeline/timeline-merge.ts +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -108,6 +108,52 @@ function appendTimelineRowsPreservingOrder( } } +function canCoalesceCompletedTurnPageSeam( + older: TimelineRow | undefined, + newer: TimelineRow | undefined, +): older is Extract { + return ( + older?.kind === "turn" && + newer?.kind === "turn" && + older.threadId === newer.threadId && + older.turnId === newer.turnId && + older.completedAt !== null && + newer.completedAt !== null && + older.sourceSeqEnd < newer.sourceSeqStart + ); +} + +function coalesceCompletedTurnPageSeam( + older: Extract, + newer: Extract, +): Extract { + if (older.completedAt === null || newer.completedAt === null) { + throw new Error("Cannot coalesce unfinished turn rows"); + } + const children = [ + ...new Map( + [older, newer] + .flatMap((part) => part.children ?? []) + .map((child) => [child.id, child]), + ).values(), + ]; + return { + ...newer, + // The older page owns the stable position of the combined transport + // fragment. Visible rows at either side of the seam prevent this helper + // from running, so a semantic conversation boundary is never crossed. + id: older.id, + children: + older.children === null && newer.children === null ? null : children, + completedAt: Math.max(older.completedAt, newer.completedAt), + createdAt: Math.min(older.createdAt, newer.createdAt), + sourceSeqEnd: newer.sourceSeqEnd, + sourceSeqStart: older.sourceSeqStart, + startedAt: Math.min(older.startedAt, newer.startedAt), + summaryCount: older.summaryCount + newer.summaryCount, + }; +} + function timelineRowIdentitySignature(row: TimelineRow): string { const turnRequest = row.kind === "conversation" && row.role === "user" ? row.turnRequest : null; @@ -170,7 +216,72 @@ export function prependOlderTimelineRows({ }: PrependOlderTimelineRowsArgs): TimelineRow[] { const rows: TimelineRow[] = []; appendTimelineRowsPreservingOrder(rows, olderRows); - appendTimelineRowsPreservingOrder(rows, loadedRows); + const older = rows.at(-1); + const newer = loadedRows[0]; + if ( + canCoalesceCompletedTurnPageSeam(older, newer) && + newer?.kind === "turn" + ) { + rows[rows.length - 1] = coalesceCompletedTurnPageSeam(older, newer); + appendTimelineRowsPreservingOrder(rows, loadedRows.slice(1)); + } else { + appendTimelineRowsPreservingOrder(rows, loadedRows); + } + 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; } diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index 353da9243a..309fe47831 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { ThreadTimelineResponse, TimelineCommandWorkRow, + TimelineConversationRow, + TimelineDelegationWorkRow, TimelinePaginationCursor, TimelineRow, TimelineTurnRow, @@ -10,6 +12,7 @@ import type { import { mergeLoadedTimelineWithLatest, mergeLatestTimelineRows, + mergeTimelineTurnDetailPages, prependOlderTimelineRows, recoverLoadedTimelineAfterStaleCursor, type LoadedTimelineState, @@ -84,6 +87,23 @@ function commandRow(args: TimelineTestRowArgs): TimelineCommandWorkRow { }; } +function assistantRow(args: TimelineTestRowArgs): TimelineConversationRow { + return { + id: args.id, + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: args.sequence, + sourceSeqEnd: args.endSequence ?? args.sequence, + startedAt: args.sequence, + createdAt: args.sequence, + kind: "conversation", + role: "assistant", + text: args.id, + attachments: null, + turnRequest: null, + }; +} + function turnSummaryRow(args: TimelineTurnTestRowArgs): TimelineTurnRow { return { id: args.id, @@ -101,6 +121,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 +231,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 +241,15 @@ describe("timeline page row merging", () => { commandRow({ id: "command-4", sequence: 21 }), ]; const olderSlice = turnSummaryRow({ - id: "turn-1:sequence-page:10", + id: "turn-1:0:sequence-page:10", sequence: 10, + endSequence: 11, children: olderCommands, }); const latestSlice = turnSummaryRow({ - id: "turn-1:sequence-page:20", + id: "turn-1:0:sequence-page:20", sequence: 20, + endSequence: 21, children: latestCommands, }); @@ -208,10 +258,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([olderSlice.id]); + 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 +278,62 @@ describe("timeline page row merging", () => { ).toEqual(["command-1", "command-2", "command-3", "command-4"]); }); + it("does not coalesce work across a visible assistant reply", () => { + const firstSlice = turnSummaryRow({ + id: "turn-1:0:sequence-page:10", + sequence: 10, + endSequence: 11, + }); + const visibleReply = assistantRow({ id: "assistant-1", sequence: 20 }); + const secondSlice = turnSummaryRow({ + id: "turn-1:1:sequence-page:20", + sequence: 21, + endSequence: 22, + }); + + const rows = prependOlderTimelineRows({ + olderRows: [firstSlice], + loadedRows: [visibleReply, secondSlice], + }); + + expect(rows.map((row) => row.id)).toEqual([ + firstSlice.id, + visibleReply.id, + secondSlice.id, + ]); + }); + + 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..8904a0e266 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1,5 +1,6 @@ import { and, + asc, desc, eq, gt, @@ -1226,6 +1227,15 @@ export interface ListStoredTimelineWindowEventRowsArgs { threadId: string; } +export interface FindStoredTurnAssistantMessageContextRowsArgs { + afterSequence: number; + beforeSequence: number; + /** See {@link InlineOutputCharLimit}. */ + maxInlineOutputChars: InlineOutputCharLimit; + threadId: string; + turnId: string; +} + export type GetStoredTimelineWindowEventDataBytesArgs = ListStoredTimelineWindowEventRowsArgs; @@ -1234,6 +1244,24 @@ export interface FindStoredTimelineWindowByteBudgetFloorArgs maxDataBytes: number; } +export interface ReadStoredTimelineWindowForwardPageArgs + extends ListStoredTimelineWindowEventRowsArgs { + beforeSequence: number; + maxDataBytes: 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 } @@ -1560,6 +1588,38 @@ export function findStoredEventRow( ); } +export function findStoredTurnAssistantMessageContextRows( + db: DbConnection, + args: FindStoredTurnAssistantMessageContextRowsArgs, +): { after: StoredEventRow | null; before: StoredEventRow | null } { + const fields = storedEventRowFieldsWithInlineOutputLimit( + args.maxInlineOutputChars, + ); + const scope = [ + eq(events.threadId, args.threadId), + eq(events.turnId, args.turnId), + eq(events.type, "item/completed"), + eq(events.itemKind, "agentMessage"), + ]; + const before = + db + .select(fields) + .from(events) + .where(and(...scope, lt(events.sequence, args.beforeSequence))) + .orderBy(desc(events.sequence)) + .limit(1) + .get() ?? null; + const after = + db + .select(fields) + .from(events) + .where(and(...scope, gt(events.sequence, args.afterSequence))) + .orderBy(asc(events.sequence)) + .limit(1) + .get() ?? null; + return { after, before }; +} + export function listStoredEventRowsByParentToolCallIds( db: DbConnection, args: ListStoredEventRowsByParentToolCallIdsArgs, @@ -3109,13 +3169,66 @@ 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 byte-bounded prefix of a sequence range. */ +export function readStoredTimelineWindowForwardPage( + db: DbConnection, + args: ReadStoredTimelineWindowForwardPageArgs, +): StoredTimelineWindowForwardPage { + const data = storedTimelineWindowDataColumn(args.maxInlineOutputChars); + // 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({ + dataBytes: sql`length(CAST(${data} AS BLOB))`.as("data_bytes"), + sequence: events.sequence, + }) + .from(events) + .where(and(...storedTimelineWindowConditions(args))) + .orderBy(events.sequence) + .toSQL(); + const statement = db.$client.prepare< + unknown[], + { data_bytes: number; sequence: number } + >(query.sql); + let dataBytes = 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: row.data_bytes, + kind: "single-event-too-large", + sequence: row.sequence, + }; + } + nextSequenceStart = row.sequence; + break; + } + dataBytes += row.data_bytes; + hasRows = true; + } + + 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( db: DbConnection, args: { diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 9c9f1c6d70..7595d8afec 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -237,6 +237,7 @@ export { deleteThreadEventSuffixInTransaction, getHighWaterMarks, findStoredEventRow, + findStoredTurnAssistantMessageContextRows, getActiveStoredTurnId, hasRootStoredTurnStarted, hasStoredTurnStarted, @@ -257,6 +258,7 @@ export { listTimelineSegmentAnchorsDescending, findTimelineWindowBudgetFloorSequence, findStoredTimelineWindowByteBudgetFloor, + readStoredTimelineWindowForwardPage, getStoredEventRowsByParentToolCallIdsDataBytes, findUnfinishedTurnCoveringSequence, hasParentedEventCrossingSequence, @@ -305,6 +307,7 @@ export type { ScopedItemRef, StoredEventRow, StandardTimelineSegmentAnchorRow, + StoredTimelineWindowForwardPage, ThreadClientTurnRequestKey, StoredTurnRequestEventRow, } from "./events.js"; 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/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 241fd75256..bd47c73406 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.20"; +export const PLUGIN_SDK_VERSION = "0.4.21"; /** 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 1d146d3d6e..e15b7d484c 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.20", + "version": "0.4.21", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index d1a25551d7..bec7bc839a 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,22 @@ 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, + sourceSeqStart: input.sourceSeqStart, + sourceSeqEnd: input.sourceSeqEnd, + ...(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..3b27d876b3 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -737,6 +737,16 @@ export type TimelineTurnSummaryDetailsQuery = z.infer< typeof timelineTurnSummaryDetailsQuerySchema >; +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< + typeof timelineTurnDetailsQuerySchema +>; + export const threadEventsQuerySchema = z .object({ afterSeq: z.string().regex(/^\d+$/), @@ -824,6 +834,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..bb3586f71a 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,16 @@ 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", + sourceSeqStart: "1", + sourceSeqEnd: "2", + }, + }).pathname, + ).toBe("/api/v1/threads/thr_123/timeline/turn-details"); expect( publicClient.threads[":id"]["thread-storage"].files.$url({ param: { id: "thr_123" }, @@ -1819,6 +1841,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..0b8c1b035b 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; /** @@ -142,6 +143,9 @@ interface ThreadTimelineSourceSeqRange { } interface BuildThreadTimelineTurnDetailsFromEventsOptions extends ThreadTimelineSourceSeqRange { + allowContextExpandedMatch?: boolean; + contextOnlyCompletedTurnIds?: ReadonlySet; + contextOnlyMessageSeqs?: ReadonlySet; includeProviderUnhandledOperations: boolean; providerDisplayName?: string; threadStatus: Thread["status"]; @@ -170,6 +174,8 @@ type ThreadTimelineTurnDetailsFromEventsResult = }; interface BuildTurnRowsArgs { + contextOnlyCompletedTurnIds?: ReadonlySet; + contextOnlyMessageSeqs?: ReadonlySet; includeNestedRows: boolean; rowIdPrefix: string; turn: EventProjectionTurn; @@ -187,7 +193,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 +211,8 @@ interface BuildCompletedTurnSummaryRowsArgs { } interface BuildTimelineRowsOptions { + contextOnlyCompletedTurnIds?: ReadonlySet; + contextOnlyMessageSeqs?: ReadonlySet; includeNestedRows: boolean; rowIdPrefix: string; workspaceRoot: string | null; @@ -1138,7 +1147,8 @@ function buildTurnSummaryRow({ completedAt, includeNestedRows, rowIdPrefix, - segmentIndex, + rowIdSegmentIndex, + sourceBounds, sourceMessages, sourceRows, startedAt, @@ -1150,13 +1160,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 +1219,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 +1235,8 @@ function buildCompletedTurnSummaryRows({ } function buildTurnRows({ + contextOnlyCompletedTurnIds, + contextOnlyMessageSeqs, includeNestedRows, rowIdPrefix, turn, @@ -1244,7 +1257,11 @@ function buildTurnRows({ } const { summaryItems, terminalMessages, trailingMessages } = - groupCompletedTurnMessages(turn); + groupCompletedTurnMessages( + turn, + contextOnlyCompletedTurnIds?.has(turn.turnId) === true, + contextOnlyMessageSeqs, + ); const terminalRows = terminalMessages.flatMap((message) => convertMessage(message, { includeNestedRows, rowIdPrefix, workspaceRoot }), ); @@ -1265,15 +1282,52 @@ type TimelineTurnSummaryRow = Extract; function findMatchingTurnSummaryRow( rows: TimelineRow[], - range: ThreadTimelineSourceSeqRange, + range: ThreadTimelineSourceSeqRange & { allowContextExpandedMatch?: boolean }, ): TimelineTurnSummaryRow | null { + const turnRows = rows.filter( + (row): row is TimelineTurnSummaryRow => row.kind === "turn", + ); + const exact = turnRows.find( + (row) => + row.sourceSeqStart === range.sourceSeqStart && + row.sourceSeqEnd === range.sourceSeqEnd, + ); + if (exact || !range.allowContextExpandedMatch) { + return exact ?? null; + } + + // Lifecycle closure and context-only rows can shift a semantic group's + // projected bounds in either direction. A parent shell can widen them, + // while removing a boundary message can narrow them. Prefer the group with + // the greatest overlap with the server-validated selection, then the least + // total boundary movement. return ( - rows.find( - (row): row is TimelineTurnSummaryRow => - row.kind === "turn" && - row.sourceSeqStart === range.sourceSeqStart && - row.sourceSeqEnd === range.sourceSeqEnd, - ) ?? null + turnRows + .filter( + (row) => + row.sourceSeqEnd >= range.sourceSeqStart && + row.sourceSeqStart <= range.sourceSeqEnd, + ) + .sort((left, right) => { + const leftOverlap = + Math.min(left.sourceSeqEnd, range.sourceSeqEnd) - + Math.max(left.sourceSeqStart, range.sourceSeqStart) + + 1; + const rightOverlap = + Math.min(right.sourceSeqEnd, range.sourceSeqEnd) - + Math.max(right.sourceSeqStart, range.sourceSeqStart) + + 1; + if (leftOverlap !== rightOverlap) { + return rightOverlap - leftOverlap; + } + const leftMovement = + Math.abs(left.sourceSeqStart - range.sourceSeqStart) + + Math.abs(left.sourceSeqEnd - range.sourceSeqEnd); + const rightMovement = + Math.abs(right.sourceSeqStart - range.sourceSeqStart) + + Math.abs(right.sourceSeqEnd - range.sourceSeqEnd); + return leftMovement - rightMovement; + })[0] ?? null ); } @@ -1365,6 +1419,8 @@ function buildTimelineRows( appendRows( rows, buildTurnRows({ + contextOnlyCompletedTurnIds: options.contextOnlyCompletedTurnIds, + contextOnlyMessageSeqs: options.contextOnlyMessageSeqs, turn: entry.turn, includeNestedRows, rowIdPrefix: options.rowIdPrefix, @@ -1400,6 +1456,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 +1507,9 @@ export function buildThreadTimelineFromEvents( }; } -export function buildThreadTimelineTurnDetailsFromEvents( +function buildThreadTimelineTurnDetailRows( args: BuildThreadTimelineTurnDetailsFromEventsArgs, -): ThreadTimelineTurnDetailsFromEventsResult { +): TimelineRow[] { const projection = buildEventProjectionEntries(args.events, { includeProviderUnhandledOperations: args.options.includeProviderUnhandledOperations, @@ -1461,11 +1518,19 @@ export function buildThreadTimelineTurnDetailsFromEvents( threadName: args.options.threadName, turnMessageDetail: "full", }); - const nestedRows = buildTimelineRows(projection, { + return buildTimelineRows(projection, { + contextOnlyCompletedTurnIds: args.options.contextOnlyCompletedTurnIds, + contextOnlyMessageSeqs: args.options.contextOnlyMessageSeqs, 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 +1560,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..edfbb5d1ac 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; } @@ -182,25 +183,38 @@ function groupCompletedTurnSummaryMessages( turn: EventProjectionTurn, summaryMessages: EventProjectionMessage[], terminalMessage: EventProjectionMessage | undefined, + useTurnBounds: boolean, + contextOnlyMessageSeqs?: ReadonlySet, ): CompletedTurnSummaryItem[] { const externalBoundarySeqs = turn.externalUserBoundarySeqs ?? []; const visibleResponseIds = findVisibleResponseMessageIds( summaryMessages, terminalMessage, ); + const selectedSummaryMessages = contextOnlyMessageSeqs + ? summaryMessages.filter( + (message) => + !contextOnlyMessageSeqs.has(message.sourceSeqStart) && + !contextOnlyMessageSeqs.has(message.sourceSeqEnd), + ) + : summaryMessages; if ( + useTurnBounds && externalBoundarySeqs.length === 0 && visibleResponseIds.size === 0 && - !summaryMessages.some(isTimelineUngroupableMessage) + !selectedSummaryMessages.some(isTimelineUngroupableMessage) ) { return [ { kind: "summary", startedAt: turn.startedAt, completedAt: turn.completedAt, - segmentIndex: null, - sourceMessages: summaryMessages, - summaryCount: turn.summaryCount, + rowIdSegmentIndex: null, + sourceBounds: "turn", + sourceMessages: selectedSummaryMessages, + summaryCount: contextOnlyMessageSeqs + ? getProjectionSummaryCount(selectedSummaryMessages, undefined) + : turn.summaryCount, }, ]; } @@ -220,7 +234,8 @@ function groupCompletedTurnSummaryMessages( kind: "summary", startedAt: bounds.startedAt, completedAt: null, - segmentIndex, + rowIdSegmentIndex: segmentIndex, + sourceBounds: "messages", sourceMessages, summaryCount: getProjectionSummaryCount(sourceMessages, undefined), }); @@ -267,7 +282,7 @@ function groupCompletedTurnSummaryMessages( } } - for (const message of summaryMessages) { + for (const message of selectedSummaryMessages) { flushExternalBoundariesBefore(message); if (visibleResponseIds.has(message.id)) { flushGroupedMessages(); @@ -295,21 +310,28 @@ function groupCompletedTurnSummaryMessages( externalBoundaryIndex += 1; } flushGroupedMessages(); - return applySingleSummaryTurnBounds(turn, items); + return useTurnBounds ? applySingleSummaryTurnBounds(turn, items) : items; } export function groupCompletedTurnMessages( turn: EventProjectionTurn, + completionIsContextOnly = false, + contextOnlyMessageSeqs?: ReadonlySet, ): CompletedTurnMessageGroups { const messages = turn.messages ?? []; const { summaryMessages, terminalMessages, trailingMessages } = - splitCompletedTurnMessages(messages, turn.terminalMessage); + splitCompletedTurnMessages( + messages, + completionIsContextOnly ? undefined : turn.terminalMessage, + ); return { summaryItems: unwrapSingletonContextManagementGroups( groupCompletedTurnSummaryMessages( turn, summaryMessages, terminalMessages[0], + !completionIsContextOnly, + contextOnlyMessageSeqs, ), ), terminalMessages, 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/test/completed-turn-grouping.test.ts b/packages/thread-view/test/completed-turn-grouping.test.ts index 19d509f4b6..0a0768dc37 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: 0, + sourceBounds: "messages", sourceMessages: [{ id: "narration" }, { id: "command" }], summaryCount: 2, }, @@ -234,6 +236,107 @@ describe("groupCompletedTurnMessages", () => { expect(groups.terminalMessages).toEqual([hookReply]); }); + it("keeps segmented 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: 0, + sourceBounds: "messages", + sourceMessages: [{ id: "narration" }, { id: "command" }], + }, + { kind: "ungrouped-message", message: { id: "answer" } }, + ]); + }); + + it("keeps work segmented around visible assistant replies", () => { + 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: 0, + sourceMessages: [{ id: "narration-1" }, { id: "command-1" }], + }, + { kind: "ungrouped-message", message: { id: "visible-reply" } }, + { + kind: "summary", + rowIdSegmentIndex: 1, + sourceMessages: [{ id: "narration-2" }, { id: "command-2" }], + }, + ]); + }); + + 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: 0, + 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 d78e123b59..e392f56227 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: