diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index 437c0c3c0..2ba95c220 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -175,4 +175,60 @@ describe("useThreadTimelineController", () => { expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); }); }); + + it("uses the bounded app page size when loading older timeline rows", async () => { + const latest = { + ...makeTimelineResponse(), + rows: [makeUserRow("thread-1:user-seed:10", 10)], + timelinePage: { + hasOlderRows: true, + kind: "latest" as const, + olderCursor: { + anchorId: "thread-1:user-seed:10", + anchorSeq: 10, + }, + returnedSegmentCount: 1, + segmentLimit: 8, + }, + }; + const older = { + ...makeTimelineResponse(), + rows: [makeUserRow("thread-1:user-seed:1", 1)], + timelinePage: { + hasOlderRows: false, + kind: "older" as const, + olderCursor: null, + returnedSegmentCount: 1, + segmentLimit: 8, + }, + }; + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(latest) + .mockResolvedValueOnce(older); + + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.hasOlderTimelineRows).toBe(true); + }); + + await act(async () => { + await result.current.loadOlderTimelineRows(); + }); + + expect(vi.mocked(sdk.threads.timeline).mock.calls[1]?.[0]).toEqual({ + beforeAnchorId: "thread-1:user-seed:10", + beforeAnchorSeq: "10", + segmentLimit: "8", + threadId: "thread-1", + }); + expect(result.current.timelineRows.map((row) => row.id)).toEqual([ + "thread-1:user-seed:1", + "thread-1:user-seed:10", + ]); + }); }); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 6d48822b9..363e5de1f 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -9,6 +9,7 @@ import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { useThreadTimeline } from "@/hooks/queries/thread-queries"; import { isOptimisticTimelineRowId } from "@/lib/optimistic-timeline-row"; import { BbHttpError, sdk } from "@/lib/sdk"; +import { APP_THREAD_TIMELINE_SEGMENT_LIMIT } from "@/lib/thread-timeline-window"; export type ThreadTimelineRowFilter = (row: TimelineRow) => boolean; @@ -488,6 +489,7 @@ export function useThreadTimelineController({ const response = await sdk.threads.timeline({ beforeAnchorId: nextOlderCursor.anchorId, beforeAnchorSeq: String(nextOlderCursor.anchorSeq), + segmentLimit: APP_THREAD_TIMELINE_SEGMENT_LIMIT, threadId, }); const olderRows = filterTimelineRows({ diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx index 554be480e..900d0e4ee 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx @@ -74,6 +74,33 @@ function userConversationRow(index = 1): TimelineRow { }; } +function assistantConversationRow({ + id, + index, + sourceSeqStart = index, + text, +}: { + id: string; + index: number; + sourceSeqStart?: number; + text: string; +}): TimelineRow { + return { + id, + threadId: "thr_toc_test", + turnId: `turn_${index}`, + sourceSeqStart, + sourceSeqEnd: index, + startedAt: index, + createdAt: index, + kind: "conversation", + role: "assistant", + text, + attachments: null, + turnRequest: null, + }; +} + function TocHost({ hasOlderTimelineRows = false, loadOlderTimelineRows = () => {}, @@ -161,13 +188,17 @@ function createScrollElement({ function outlineResponse( items: ThreadConversationOutlineItem[], + maxSeq = items.length, ): ThreadConversationOutlineResponse { - return { items, maxSeq: items.length }; + return { items, maxSeq }; } -function setOutline(items: ThreadConversationOutlineItem[] | undefined): void { +function setOutline( + items: ThreadConversationOutlineItem[] | undefined, + maxSeq?: number, +): void { vi.mocked(useThreadConversationOutline).mockReturnValue({ - data: items === undefined ? undefined : outlineResponse(items), + data: items === undefined ? undefined : outlineResponse(items, maxSeq), } as ReturnType); } @@ -611,6 +642,205 @@ describe("ThreadTableOfContents", () => { expect(screen.getByText("Agent messages")).not.toBeNull(); }); + it("overlays live assistant previews and rows on the stable full outline", async () => { + setOutline( + [ + { + id: "u1", + role: "user", + preview: "First question", + attachmentSummary: null, + }, + { + id: "u2", + role: "user", + preview: "Second question", + attachmentSummary: null, + }, + { + id: "u3", + role: "user", + preview: "Third question", + attachmentSummary: null, + }, + { + id: "a0", + role: "assistant", + preview: "Stored historical preview", + attachmentSummary: null, + }, + { + id: "a1", + role: "assistant", + preview: "Stale streaming preview", + attachmentSummary: null, + }, + ], + 10, + ); + + render( + , + ); + openTocPanel(); + fireEvent.click(await screen.findByText("Agent messages")); + + expect(screen.getByText("Stored historical preview")).not.toBeNull(); + expect( + screen.queryByText("Loaded historical text must not win"), + ).toBeNull(); + expect(screen.getByText("Current streaming preview")).not.toBeNull(); + expect(screen.getByText("New streaming row")).not.toBeNull(); + expect(screen.queryByText("Stale streaming preview")).toBeNull(); + }); + + it("does not rebind scroll-spy when only a live assistant label changes", async () => { + const storedOutline: ThreadConversationOutlineItem[] = [ + { + id: "u1", + role: "user", + preview: "First question", + attachmentSummary: null, + }, + { + id: "u2", + role: "user", + preview: "Second question", + attachmentSummary: null, + }, + { + id: "u3", + role: "user", + preview: "Third question", + attachmentSummary: null, + }, + ]; + setOutline(storedOutline, 10); + const addEventListener = vi.spyOn(scrollElement, "addEventListener"); + const view = render( + , + ); + openTocPanel(); + fireEvent.click(await screen.findByText("Agent messages")); + expect(await screen.findByText("First live text")).not.toBeNull(); + const scrollListenerAdds = () => + addEventListener.mock.calls.filter(([type]) => type === "scroll").length; + expect(scrollListenerAdds()).toBe(1); + + view.rerender( + , + ); + + expect(await screen.findByText("Second live text")).not.toBeNull(); + expect(scrollListenerAdds()).toBe(1); + + setOutline( + [ + ...storedOutline, + { + id: "a-live", + role: "assistant", + preview: "Final stored text", + attachmentSummary: null, + }, + ], + 12, + ); + view.rerender( + , + ); + + expect(await screen.findByText("Final stored text")).not.toBeNull(); + expect(screen.getAllByText("Final stored text")).toHaveLength(1); + expect(screen.queryByText("Second live text")).toBeNull(); + }); + + it("caps live assistant previews to the outline payload limit", async () => { + setOutline( + [ + { + id: "u1", + role: "user", + preview: "First question", + attachmentSummary: null, + }, + { + id: "u2", + role: "user", + preview: "Second question", + attachmentSummary: null, + }, + { + id: "u3", + role: "user", + preview: "Third question", + attachmentSummary: null, + }, + ], + 10, + ); + const boundedPreview = "x".repeat(200); + render( + , + ); + openTocPanel(); + fireEvent.click(await screen.findByText("Agent messages")); + + expect(screen.getByText(boundedPreview)).not.toBeNull(); + expect(screen.queryByText(`${boundedPreview} overflow`)).toBeNull(); + }); + it("renders an agent-to-agent message source as a thread mention", async () => { setOutline([ { @@ -896,6 +1126,33 @@ describe("ThreadTableOfContents", () => { ); }); + it("tracks a live assistant row that is newer than the outline", () => { + const scrollElement = createScrollElement({ + clientHeight: 100, + scrollHeight: 1_000, + scrollTop: 400, + rows: [{ id: "agent-live", top: 20, bottom: 80 }], + }); + + expect( + findActiveItemIds({ + agentItems: [], + scrollElement, + supplementalAgentItems: [ + { + id: "agent-live", + label: "Streaming response", + role: "assistant", + }, + ], + userItems: [], + }), + ).toEqual({ + agent: "agent-live", + user: null, + }); + }); + it("does not mark a role active at the bottom when that role is offscreen", () => { const scrollElement = createScrollElement({ clientHeight: 100, diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx index e235c087d..b578c1bdd 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx @@ -20,6 +20,10 @@ export interface TocItem { role: "user" | "assistant"; } +interface TimelineTocItem extends TocItem { + sourceSeqEnd: number; +} + type TocTab = "user" | "agent"; interface ActiveItemIds { @@ -45,6 +49,8 @@ const TOC_BOTTOM_ACTIVE_THRESHOLD_PX = 4; // Only worth showing once the conversation has enough user turns to navigate. const TOC_MIN_USER_MESSAGES = 3; const TOC_MAX_RAIL_TICKS = 20; +// Matches the server's conversation-outline preview payload. +const TOC_PREVIEW_MAX_LENGTH = 200; // Updating the active rail tick changes overlay DOM and invalidates layout. // Wait for a scroll burst to settle instead of doing that work in the same // animation frames the timeline is trying to paint. @@ -78,7 +84,9 @@ function toTocLabel({ text: string; }): string { const textLabel = toPreviewLabel(text); - return textLabel || toAttachmentPreviewLabel(attachments); + const label = textLabel || toAttachmentPreviewLabel(attachments); + if (label.length <= TOC_PREVIEW_MAX_LENGTH) return label; + return label.slice(0, TOC_PREVIEW_MAX_LENGTH).trimEnd(); } function toAttachmentSummaryLabel( @@ -222,15 +230,17 @@ function TocItemPreview({ } /** - * Builds the user/agent item lists for the minimap. Prefers the full - * conversation outline (the whole thread, independent of pagination); falls - * back to the loaded timeline window so the minimap still renders on first - * paint and in environments without the outline endpoint (e.g. stories). + * Builds the user/agent item lists for the minimap. The full outline owns + * history while the loaded timeline window supplies live assistant labels and + * rows. Keeping those layers separate is important: streaming updates must not + * walk and recreate the entire outline (#898). */ function useConversationTocItems({ + outlineMaxSeq, outlineItems, timelineRows, }: { + outlineMaxSeq: number | undefined; outlineItems: readonly ThreadConversationOutlineItem[] | undefined; timelineRows: readonly TimelineRow[]; }) { @@ -238,26 +248,29 @@ function useConversationTocItems({ if (!outlineItems || outlineItems.length === 0) return null; const userItems: TocItem[] = []; const agentItems: TocItem[] = []; + const agentItemIds = new Set(); for (const item of outlineItems) { const tocItem = outlineItemToTocItem(item); if (tocItem.role === "user") { userItems.push(tocItem); } else { agentItems.push(tocItem); + agentItemIds.add(tocItem.id); } } - return { agentItems, userItems }; + return { agentItemIds, agentItems, userItems }; }, [outlineItems]); const timelineTocItems = useMemo(() => { - const userItems: TocItem[] = []; - const agentItems: TocItem[] = []; + const userItems: TimelineTocItem[] = []; + const agentItems: TimelineTocItem[] = []; for (const row of timelineRows) { if (row.kind !== "conversation") continue; - const item: TocItem = { + const item: TimelineTocItem = { id: row.id, label: toTocLabel({ attachments: row.attachments, text: row.text }), role: row.role, + sourceSeqEnd: row.sourceSeqEnd, }; if (row.role === "user") { userItems.push(item); @@ -269,7 +282,40 @@ function useConversationTocItems({ return { agentItems, userItems }; }, [timelineRows]); - return outlineTocItems ?? timelineTocItems; + const liveAgentOverlay = useMemo(() => { + if (!outlineTocItems || outlineMaxSeq === undefined) { + return { + itemsById: new Map(), + supplementalItemIdsKey: "", + supplementalItems: [] as TocItem[], + }; + } + const itemsById = new Map(); + const supplementalItems: TocItem[] = []; + for (const item of timelineTocItems.agentItems) { + if (item.sourceSeqEnd <= outlineMaxSeq) continue; + itemsById.set(item.id, item); + if (!outlineTocItems.agentItemIds.has(item.id)) { + supplementalItems.push(item); + } + } + return { + itemsById, + supplementalItemIdsKey: JSON.stringify( + supplementalItems.map((item) => item.id), + ), + supplementalItems, + }; + }, [outlineMaxSeq, outlineTocItems, timelineTocItems.agentItems]); + + const baseItems = outlineTocItems ?? timelineTocItems; + return { + agentItems: baseItems.agentItems, + liveAgentItemsById: liveAgentOverlay.itemsById, + supplementalAgentItemIdsKey: liveAgentOverlay.supplementalItemIdsKey, + supplementalAgentItems: liveAgentOverlay.supplementalItems, + userItems: baseItems.userItems, + }; } function useThreadTocVisible(rootElement: HTMLDivElement | null): boolean { @@ -422,13 +468,20 @@ function findLastVisibleItemId({ export function findActiveItemIds({ agentItems, scrollElement, + supplementalAgentItems = [], userItems, }: { agentItems: readonly TocItem[]; scrollElement: HTMLElement | null; + supplementalAgentItems?: readonly TocItem[]; userItems: readonly TocItem[]; }): ActiveItemIds { - if (!scrollElement || (userItems.length === 0 && agentItems.length === 0)) { + if ( + !scrollElement || + (userItems.length === 0 && + agentItems.length === 0 && + supplementalAgentItems.length === 0) + ) { return { agent: null, user: null }; } const scrollRect = scrollElement.getBoundingClientRect(); @@ -438,6 +491,9 @@ export function findActiveItemIds({ const rolesById = new Map(); for (const item of userItems) rolesById.set(item.id, "user"); for (const item of agentItems) rolesById.set(item.id, "agent"); + for (const item of supplementalAgentItems) { + rolesById.set(item.id, "agent"); + } const userRows: HTMLElement[] = []; const agentRows: HTMLElement[] = []; @@ -501,8 +557,15 @@ export function ThreadTableOfContents({ enabled: timelineRows.length > 0, }); const senderThreadMetadataById = useSenderThreadMetadataById(); - const { agentItems, userItems } = useConversationTocItems({ + const { + agentItems, + liveAgentItemsById, + supplementalAgentItemIdsKey, + supplementalAgentItems, + userItems, + } = useConversationTocItems({ outlineItems: outlineQuery.data?.items, + outlineMaxSeq: outlineQuery.data?.maxSeq, timelineRows, }); const [rootElement, setRootElement] = useState(null); @@ -524,7 +587,10 @@ export function ThreadTableOfContents({ }); const itemEls = useRef(new Map()); const activeIdsRef = useRef({ agent: null, user: null }); - const hasAgentMessages = agentItems.length > 0; + const supplementalAgentItemsRef = useRef(supplementalAgentItems); + supplementalAgentItemsRef.current = supplementalAgentItems; + const hasAgentMessages = + agentItems.length > 0 || supplementalAgentItems.length > 0; const activeTab = tab === "agent" && hasAgentMessages ? "agent" : "user"; const items = activeTab === "user" ? userItems : agentItems; const activeId = activeTab === "user" ? activeUserId : activeAgentId; @@ -567,7 +633,12 @@ export function ThreadTableOfContents({ }; const updateActiveItems = () => { publishActiveItems( - findActiveItemIds({ agentItems, scrollElement, userItems }), + findActiveItemIds({ + agentItems, + scrollElement, + supplementalAgentItems: supplementalAgentItemsRef.current, + userItems, + }), ); }; let updateTimeout: number | null = null; @@ -597,7 +668,13 @@ export function ThreadTableOfContents({ window.clearTimeout(updateTimeout); } }; - }, [agentItems, bottomAnchor, tocVisible, userItems]); + }, [ + agentItems, + bottomAnchor, + supplementalAgentItemIdsKey, + tocVisible, + userItems, + ]); useEffect(() => { if (!tocVisible || !open) return; @@ -679,6 +756,45 @@ export function ThreadTableOfContents({ if (userItems.length < TOC_MIN_USER_MESSAGES) return null; + const renderPanelItem = (item: TocItem) => { + const displayedItem = + activeTab === "agent" ? (liveAgentItemsById.get(item.id) ?? item) : item; + const active = item.id === activeId; + const pending = item.id === pendingJumpId; + return ( +
  • + +
  • + ); + }; + return (
      - {items.map((item) => { - const active = item.id === activeId; - const pending = item.id === pendingJumpId; - return ( -
    • - -
    • - ); - })} + {items.map(renderPanelItem)} + {activeTab === "agent" + ? supplementalAgentItems.map(renderPanelItem) + : null}
    = + new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/plan/delta", + "item/mcpToolCall/progress", + "item/toolCall/progress", + "item/backgroundTask/progress", + "thread/tokenUsage/updated", + "thread/contextWindowUsage/updated", + "turn/plan/updated", + "turn/diff/updated", + "provider/unhandled", + ]); + +function shouldInvalidateThreadConversationOutline( + eventTypes: readonly ThreadEventType[] | undefined, +): boolean { + if (!eventTypes || eventTypes.length === 0) { + return true; + } + return eventTypes.some( + (eventType) => !THREAD_OUTLINE_STREAMING_EVENT_TYPES.has(eventType), + ); +} + function timelineInvalidationKey(queryKey: QueryKey): string { return JSON.stringify(queryKey); } @@ -660,15 +695,24 @@ function dirtyThreadSearchQueries(): QueryKey[] { } function dirtyThreadTimelineQueries({ + eventTypes, queryClient, threadId, }: ThreadRealtimeDirtyContext): void { - // Window only: completed turn-summary-details are immutable, so realtime - // event batches must not refetch open detail panels (see helper docs). + // Keep the loaded window live for every append. The full-thread outline is a + // separate projection: rebuilding it for token/progress events blocks the + // server event loop without adding a stable navigation row. invalidateQueryKeysWithoutCancelingActiveFetches({ queryClient, queryKeys: getThreadTimelineWindowInvalidationQueryKeys({ threadId }), }); + if (!shouldInvalidateThreadConversationOutline(eventTypes)) { + return; + } + invalidateQueryKeysWithoutCancelingActiveFetches({ + queryClient, + queryKeys: getThreadConversationOutlineInvalidationQueryKeys({ threadId }), + }); } function dirtyThreadQueueContentQueries({ diff --git a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.test.ts new file mode 100644 index 000000000..018e9a3f1 --- /dev/null +++ b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.test.ts @@ -0,0 +1,53 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { + ThreadTimelineResponse, + ThreadWithIncludesResponse, +} from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { ingestThreadDetailBootstrap } from "./thread-detail-cache-owner"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { + threads: { + timeline: vi.fn(), + }, + }, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("ingestThreadDetailBootstrap", () => { + it("prefetches the bounded app timeline window", async () => { + vi.mocked(sdk.threads.timeline).mockResolvedValue( + {} as ThreadTimelineResponse, + ); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const thread = { + environment: null, + host: null, + id: "thread-1", + } as ThreadWithIncludesResponse; + + ingestThreadDetailBootstrap({ + queryClient, + thread, + timelinePrefetch: true, + }); + + await vi.waitFor(() => { + expect(sdk.threads.timeline).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(sdk.threads.timeline).mock.calls[0]?.[0]).toEqual({ + segmentLimit: "8", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }); + + queryClient.clear(); + }); +}); diff --git a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts index cb87ceda7..2baba305d 100644 --- a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts @@ -5,6 +5,7 @@ import type { ThreadWithIncludesResponse, } from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; +import { APP_THREAD_TIMELINE_SEGMENT_LIMIT } from "@/lib/thread-timeline-window"; import { environmentQueryKey, hostQueryKey, @@ -80,7 +81,11 @@ export function ingestThreadDetailBootstrap({ void queryClient.prefetchQuery({ queryKey: threadTimelineQueryKey(thread.id), queryFn: ({ signal }) => - sdk.threads.timeline({ signal, threadId: thread.id }), + sdk.threads.timeline({ + segmentLimit: APP_THREAD_TIMELINE_SEGMENT_LIMIT, + signal, + threadId: thread.id, + }), }); } } diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index b1a2d171c..e61299379 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -2,6 +2,7 @@ import { cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ThreadTimelineResponse } from "@bb/server-contract"; import * as api from "@/lib/api"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; @@ -9,11 +10,13 @@ import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; import { threadHostFilePreviewQueryKey, threadQueuedMessagesQueryKey, + threadTimelineQueryKey, } from "./query-keys"; import { useArchivedThreads, useThreadHostFilePreview, useThreadQueuedMessages, + useThreadTimeline, } from "./thread-queries"; vi.mock("@/lib/api", async (importOriginal) => { @@ -29,6 +32,7 @@ vi.mock("@/lib/sdk", () => ({ threads: { list: vi.fn(), queuedMessages: { list: vi.fn() }, + timeline: vi.fn(), }, }, })); @@ -46,6 +50,7 @@ afterEach(() => { beforeEach(() => { vi.mocked(sdk.threads.list).mockResolvedValue([]); vi.mocked(sdk.threads.queuedMessages.list).mockResolvedValue([]); + vi.mocked(sdk.threads.timeline).mockResolvedValue(makeTimelineResponse()); vi.mocked(api.getThreadHostFilePreview).mockResolvedValue({ kind: "text", path: "/tmp/log.txt", @@ -55,6 +60,27 @@ beforeEach(() => { }); }); +function makeTimelineResponse(maxSeq = 0): ThreadTimelineResponse { + return { + activeBackgroundCommands: [], + activePromptMode: null, + activeThinking: null, + activeWorkflows: [], + goal: null, + maxSeq, + modelFallback: null, + pendingTodos: null, + rows: [], + timelinePage: { + hasOlderRows: false, + kind: "latest", + olderCursor: null, + returnedSegmentCount: 0, + segmentLimit: 8, + }, + }; +} + describe("useArchivedThreads", () => { it("loads archived threads across all projects when no scope is selected", async () => { const { wrapper } = createQueryClientTestHarness(); @@ -132,6 +158,61 @@ describe("useThreadQueuedMessages", () => { }); }); +describe("useThreadTimeline", () => { + it("bounds the initial app timeline window", async () => { + const { wrapper } = createQueryClientTestHarness(); + + renderHook(() => useThreadTimeline("thread-1"), { wrapper }); + + await waitFor(() => { + expect(sdk.threads.timeline).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(sdk.threads.timeline).mock.calls[0]?.[0]).toEqual({ + segmentLimit: "8", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }); + }); + + it("keeps the app window bound when a stale delta needs a full fetch", async () => { + const previous = makeTimelineResponse(5); + const staleDelta = { + ...makeTimelineResponse(6), + delta: { rowOrder: ["missing-row"], upsertRows: [] }, + }; + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(staleDelta) + .mockResolvedValueOnce(makeTimelineResponse(6)); + const { queryClient, wrapper } = createQueryClientTestHarness(); + queryClient.setQueryData(threadTimelineQueryKey("thread-1"), previous, { + updatedAt: 1, + }); + + renderHook(() => useThreadTimeline("thread-1"), { wrapper }); + + await waitFor(() => { + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + }); + expect(vi.mocked(sdk.threads.timeline).mock.calls).toEqual([ + [ + { + afterSequence: "5", + segmentLimit: "8", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }, + ], + [ + { + segmentLimit: "8", + signal: expect.any(AbortSignal), + threadId: "thread-1", + }, + ], + ]); + }); +}); + describe("useThreadHostFilePreview", () => { it("refetches stale host file previews on focus and reconnect", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index e0d40286a..09535911c 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -26,6 +26,7 @@ import type { ThreadListFilters } from "@/lib/api-types"; import type { FilePreview } from "@/lib/file-preview"; import type { PathListOptions } from "@/lib/path-list-options"; import type { ThreadStorageFileListOptions } from "@/lib/thread-storage-files"; +import { APP_THREAD_TIMELINE_SEGMENT_LIMIT } from "@/lib/thread-timeline-window"; import * as api from "@/lib/api"; import { sdk } from "@/lib/sdk"; import { @@ -769,6 +770,7 @@ export function useThreadTimeline( threadTimelineQueryKey(id), ); const response = await sdk.threads.timeline({ + segmentLimit: APP_THREAD_TIMELINE_SEGMENT_LIMIT, threadId, signal, ...(previous?.maxSeq !== undefined @@ -776,7 +778,11 @@ export function useThreadTimeline( : {}), }); return mergeThreadTimelineDelta(previous, response, () => - sdk.threads.timeline({ threadId, signal }), + sdk.threads.timeline({ + segmentLimit: APP_THREAD_TIMELINE_SEGMENT_LIMIT, + threadId, + signal, + }), ); }, enabled, @@ -799,9 +805,9 @@ export function useThreadTimeline( * Full conversation outline (every user/agent message) for a thread's * table-of-contents minimap. Unlike {@link useThreadTimeline}, this is not * paginated — it always reflects the whole thread — so the minimap can show - * messages that have not yet been scrolled/paged into the loaded window. It is - * invalidated by the same realtime `events-appended` signal as the timeline - * window, so it stays in sync as new messages arrive. + * messages that have not yet been scrolled/paged into the loaded window. It + * refreshes at item and lifecycle boundaries rather than for token/progress + * events, so its message previews are stable while a turn is streaming. */ export function useThreadConversationOutline( id: string, diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 7b852fa30..1ab78bab9 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -22,6 +22,7 @@ import { projectsQueryKey, sidebarNavigationQueryKey, systemConfigQueryKey, + threadConversationOutlineQueryKey, threadDefaultExecutionOptionsQueryKey, threadQueuedMessagesQueryKey, threadListQueryKey, @@ -979,6 +980,85 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("keeps the full outline stable during high-volume streaming updates", () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const outlineKey = threadConversationOutlineQueryKey("thr_1"); + const timelineKey = threadTimelineQueryKey("thr_1"); + queryClient.setQueryData(outlineKey, { items: [], maxSeq: 1 }); + queryClient.setQueryData(timelineKey, { + rows: [], + timelinePage: { + kind: "latest", + topLevelLimit: 100, + returnedOlderTopLevelRowCount: 0, + hasOlderRows: false, + olderCursor: null, + }, + }); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { + eventTypes: [ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/plan/delta", + "item/mcpToolCall/progress", + "item/toolCall/progress", + "item/backgroundTask/progress", + "thread/tokenUsage/updated", + "thread/contextWindowUsage/updated", + "turn/plan/updated", + "turn/diff/updated", + "provider/unhandled", + ], + }, + changes: ["events-appended"], + }); + vi.advanceTimersByTime(50); + + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(outlineKey)?.isInvalidated).not.toBe(true); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { eventTypes: ["item/completed"] }, + changes: ["events-appended"], + }); + vi.advanceTimersByTime(50); + + expect(queryClient.getQueryState(outlineKey)?.isInvalidated).toBe(true); + + effects.dispose(); + }); + + it("keeps outline invalidation conservative without event metadata", () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const outlineKey = threadConversationOutlineQueryKey("thr_1"); + queryClient.setQueryData(outlineKey, { items: [], maxSeq: 1 }); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + changes: ["events-appended"], + }); + vi.advanceTimersByTime(50); + + expect(queryClient.getQueryState(outlineKey)?.isInvalidated).toBe(true); + + effects.dispose(); + }); + it("does not cancel active timeline refetches for repeated event invalidations", async () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext(); diff --git a/apps/app/src/lib/thread-timeline-window.ts b/apps/app/src/lib/thread-timeline-window.ts new file mode 100644 index 000000000..45d988882 --- /dev/null +++ b/apps/app/src/lib/thread-timeline-window.ts @@ -0,0 +1,7 @@ +/** + * User-message-anchored conversation segments mounted per timeline page. + * + * Rich message rows are expensive browser work. Keep the app window bounded; + * the full outline remains available and older pages load through the cursor. + */ +export const APP_THREAD_TIMELINE_SEGMENT_LIMIT = "8"; diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index ef555590e..2f30589a5 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -16,7 +16,6 @@ import { typedRoutes, type PublicApiSchema, type ThreadComposerBootstrapResponse, - type ThreadConversationOutlineResponse, type ThreadTimelineQuery, } from "@bb/server-contract"; import type { @@ -52,9 +51,9 @@ import { type ThreadTimelinePageKind, type ThreadTimelinePageRequest, } from "../../services/threads/timeline.js"; +import { createThreadConversationOutlineCache } from "../../services/threads/conversation-outline-cache.js"; import { createSlowThreadTimelineBuildLogger } from "../../services/threads/timeline-build-log.js"; import { - buildThreadTimelineCacheKey, buildThreadTimelineParamsKey, createThreadTimelineCache, } from "../../services/threads/timeline-cache.js"; @@ -355,21 +354,11 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { const slowTimelineBuildLogger = createSlowThreadTimelineBuildLogger({ logger: deps.logger, }); - // The conversation outline reprojects the entire thread, so memoize it per - // (thread, maxSeq): repeated polls at a stable revision are served from - // cache. Any appended event bumps maxSeq and forces a rebuild, so a thread - // streaming many deltas rebuilds per batch — acceptable because the client - // only fetches the outline when the minimap is mounted and refetches are - // driven by the (debounced) realtime invalidation, not per token. The key - // omits the provider/env inputs the timeline cache tracks because the outline - // emits only event-derived fields (id/role/preview/attachment counts); add - // them here if the outline ever surfaces a provider- or workspace-derived - // value. A small LRU bounds memory across many viewed threads. - const conversationOutlineCache = new Map< - string, - ThreadConversationOutlineResponse - >(); - const CONVERSATION_OUTLINE_CACHE_MAX_ENTRIES = 128; + // The outline reprojects the entire thread. Realtime invalidation keeps it at + // stable conversation boundaries rather than token/progress frequency; this + // cache makes duplicate reads free and retains only the newest reachable + // revision for each thread. + const conversationOutlineCache = createThreadConversationOutlineCache(); get(routes.timeline, (context, query) => { const thread = requirePublicThread(deps.db, context.req.param("id")); @@ -399,30 +388,27 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { summaryOnly, includeProviderUnhandledOperations, }; - const full = timelineCache.getOrBuild( - buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }), - () => { - const { profile, response } = buildThreadTimelineWithProfile( - deps.db, - thread, - { - eventBudget, - includeProviderUnhandledOperations, - includeNestedRows, - maxInlineOutputChars: DEFAULT_MAX_INLINE_OUTPUT_CHARS, - maxSeq, - page, - providerDisplayName, - summaryOnly, - }, - ); - slowTimelineBuildLogger.log({ profile, threadId: thread.id }); - return truncateTimelineResponseOutputs( - response, - DEFAULT_MAX_INLINE_OUTPUT_CHARS, - ); - }, - ); + const full = timelineCache.getOrBuild({ ...keyArgs, maxSeq }, () => { + const { profile, response } = buildThreadTimelineWithProfile( + deps.db, + thread, + { + eventBudget, + includeProviderUnhandledOperations, + includeNestedRows, + maxInlineOutputChars: DEFAULT_MAX_INLINE_OUTPUT_CHARS, + maxSeq, + page, + providerDisplayName, + summaryOnly, + }, + ); + slowTimelineBuildLogger.log({ profile, threadId: thread.id }); + return truncateTimelineResponseOutputs( + response, + DEFAULT_MAX_INLINE_OUTPUT_CHARS, + ); + }); // Delta: when the client tells us the revision it currently holds and our // last-sent snapshot still matches it exactly, return only the changed rows. @@ -450,32 +436,17 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { get(routes.conversationOutline, (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); const maxSeq = getLatestThreadSequence(deps.db, { threadId: thread.id }); - const cacheKey = `${thread.id}:${maxSeq}`; - const cached = conversationOutlineCache.get(cacheKey); - if (cached !== undefined) { - // Re-insert to mark most-recently-used. - conversationOutlineCache.delete(cacheKey); - conversationOutlineCache.set(cacheKey, cached); - return context.json(cached); - } - const response = buildThreadConversationOutline(deps.db, thread, { - maxSeq, - providerDisplayName: resolveThreadProviderDisplayName( - deps, - thread.providerId, + return context.json( + conversationOutlineCache.getOrBuild({ threadId: thread.id, maxSeq }, () => + buildThreadConversationOutline(deps.db, thread, { + maxSeq, + providerDisplayName: resolveThreadProviderDisplayName( + deps, + thread.providerId, + ), + }), ), - }); - conversationOutlineCache.set(cacheKey, response); - while ( - conversationOutlineCache.size > CONVERSATION_OUTLINE_CACHE_MAX_ENTRIES - ) { - const oldest = conversationOutlineCache.keys().next().value; - if (oldest === undefined) { - break; - } - conversationOutlineCache.delete(oldest); - } - return context.json(response); + ); }); get(routes.timelineTurnSummaryDetails, (context, query) => { diff --git a/apps/server/src/services/threads/conversation-outline-cache.ts b/apps/server/src/services/threads/conversation-outline-cache.ts new file mode 100644 index 000000000..ee3cffbf3 --- /dev/null +++ b/apps/server/src/services/threads/conversation-outline-cache.ts @@ -0,0 +1,67 @@ +import type { ThreadConversationOutlineResponse } from "@bb/server-contract"; + +/** + * Server-lifetime cache for full-thread conversation outlines. + * + * A thread's outline has one reachable revision: every request resolves the + * current event high-water mark before consulting this cache. Retaining older + * `maxSeq` revisions only pins projections that no caller can request again. + */ + +const DEFAULT_MAX_ENTRIES = 128; + +export interface ThreadConversationOutlineCacheOptions { + maxEntries?: number; +} + +export interface ThreadConversationOutlineCacheKey { + threadId: string; + maxSeq: number; +} + +export interface ThreadConversationOutlineCache { + getOrBuild( + key: ThreadConversationOutlineCacheKey, + build: () => ThreadConversationOutlineResponse, + ): ThreadConversationOutlineResponse; + /** Number of cached threads (for tests/metrics). */ + readonly size: number; +} + +interface ThreadConversationOutlineCacheEntry { + maxSeq: number; + response: ThreadConversationOutlineResponse; +} + +export function createThreadConversationOutlineCache( + options: ThreadConversationOutlineCacheOptions = {}, +): ThreadConversationOutlineCache { + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const entries = new Map(); + + return { + getOrBuild(key, build) { + const cached = entries.get(key.threadId); + if (cached?.maxSeq === key.maxSeq) { + entries.delete(key.threadId); + entries.set(key.threadId, cached); + return cached.response; + } + + const response = build(); + entries.delete(key.threadId); + entries.set(key.threadId, { maxSeq: key.maxSeq, response }); + while (entries.size > maxEntries) { + const oldest = entries.keys().next().value; + if (oldest === undefined) { + break; + } + entries.delete(oldest); + } + return response; + }, + get size() { + return entries.size; + }, + }; +} diff --git a/apps/server/src/services/threads/timeline-cache.ts b/apps/server/src/services/threads/timeline-cache.ts index 9533a0b4a..078afa183 100644 --- a/apps/server/src/services/threads/timeline-cache.ts +++ b/apps/server/src/services/threads/timeline-cache.ts @@ -13,9 +13,11 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js"; * (detail view + side-chat tabs), debounced realtime invalidations that fire * after the tail already settled, and re-opening a thread. * - * Keying on the thread high-water `maxSeq` makes invalidation implicit: any - * appended event bumps `maxSeq`, producing a new key and a cold rebuild. The - * key MUST also include every other input the projection depends on: + * The thread high-water `maxSeq` makes invalidation implicit: any appended + * event bumps `maxSeq`, producing a cold rebuild. Each request shape retains + * only its newest revision because the endpoint always resolves the current + * high-water sequence; client deltas use a separate latest-rows cache. The + * request shape MUST also include every other input the projection depends on: * `thread.status` (interrupt flips earlier rows), `environmentId` (workspace * root relativizes file paths), provider display name (labels dynamic-provider * diagnostic rows), and the row-shape request flags. Event pruning @@ -23,10 +25,10 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js"; * and never lowers `maxSeq`, so it cannot stale a cached entry. * * Entries with many rows are not cached: an expanded active turn (the streaming - * case) produces hundreds of rows AND a `maxSeq` that changes on every event, - * so caching it only thrashes the LRU and pins large objects for no reuse. Idle - * windows collapse completed turns to a handful of rows regardless of thread - * size, so the cap excludes exactly the entries that would never be reused. + * case) can produce hundreds of rows and a `maxSeq` that changes on every + * event, so caching it only pins large objects for no reuse. Smaller active + * windows can still fall below the row cap; replacing their prior revision + * prevents streaming updates from filling the LRU with unreachable responses. */ const DEFAULT_MAX_ENTRIES = 128; @@ -40,7 +42,7 @@ export interface ThreadTimelineCacheOptions { export interface ThreadTimelineCache { getOrBuild( - key: string, + keyArgs: ThreadTimelineCacheKeyArgs, build: () => ThreadTimelineResponse, ): ThreadTimelineResponse; /** Number of currently cached entries (for tests/metrics). */ @@ -53,21 +55,29 @@ export function createThreadTimelineCache( const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; const maxCacheableRows = options.maxCacheableRows ?? DEFAULT_MAX_CACHEABLE_ROWS; - const entries = new Map(); + const entries = new Map< + string, + { revisionKey: string; response: ThreadTimelineResponse } + >(); return { - getOrBuild(key, build) { - const cached = entries.get(key); - if (cached !== undefined) { + getOrBuild(keyArgs, build) { + const paramsKey = buildThreadTimelineParamsKey(keyArgs); + const revisionKey = buildThreadTimelineCacheKey(keyArgs); + const cached = entries.get(paramsKey); + if (cached?.revisionKey === revisionKey) { // Re-insert to mark most-recently-used. - entries.delete(key); - entries.set(key, cached); - return cached; + entries.delete(paramsKey); + entries.set(paramsKey, cached); + return cached.response; } const value = build(); + // A successful build supersedes the unreachable prior revision even when + // the new response is too large to cache. + entries.delete(paramsKey); if (value.rows.length <= maxCacheableRows) { - entries.set(key, value); + entries.set(paramsKey, { revisionKey, response: value }); while (entries.size > maxEntries) { const oldest = entries.keys().next().value; if (oldest === undefined) { diff --git a/apps/server/test/services/threads/conversation-outline-cache.test.ts b/apps/server/test/services/threads/conversation-outline-cache.test.ts new file mode 100644 index 000000000..3eb91bd68 --- /dev/null +++ b/apps/server/test/services/threads/conversation-outline-cache.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ThreadConversationOutlineResponse } from "@bb/server-contract"; +import { createThreadConversationOutlineCache } from "../../../src/services/threads/conversation-outline-cache.js"; + +function makeResponse(maxSeq: number): ThreadConversationOutlineResponse { + return { + items: [ + { + id: `row-${maxSeq}`, + role: "assistant", + preview: `Answer at ${maxSeq}`, + attachmentSummary: null, + }, + ], + maxSeq, + }; +} + +describe("createThreadConversationOutlineCache", () => { + it("builds once for the same thread revision", () => { + const cache = createThreadConversationOutlineCache(); + const build = vi.fn(() => makeResponse(10)); + + const first = cache.getOrBuild({ threadId: "thr_a", maxSeq: 10 }, build); + const second = cache.getOrBuild({ threadId: "thr_a", maxSeq: 10 }, build); + + expect(build).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + expect(cache.size).toBe(1); + }); + + it("retains only the newest revision for each thread", () => { + const cache = createThreadConversationOutlineCache(); + const build = vi.fn((maxSeq: number) => makeResponse(maxSeq)); + + for (let maxSeq = 1; maxSeq <= 128; maxSeq += 1) { + cache.getOrBuild({ threadId: "thr_a", maxSeq }, () => build(maxSeq)); + } + + expect(build).toHaveBeenCalledTimes(128); + expect(cache.size).toBe(1); + expect( + cache.getOrBuild({ threadId: "thr_a", maxSeq: 128 }, () => + makeResponse(999), + ).maxSeq, + ).toBe(128); + }); + + it("preserves the prior revision when a replacement build fails", () => { + const cache = createThreadConversationOutlineCache(); + const first = cache.getOrBuild({ threadId: "thr_a", maxSeq: 10 }, () => + makeResponse(10), + ); + + expect(() => + cache.getOrBuild({ threadId: "thr_a", maxSeq: 11 }, () => { + throw new Error("build failed"); + }), + ).toThrow("build failed"); + + expect( + cache.getOrBuild({ threadId: "thr_a", maxSeq: 10 }, () => + makeResponse(999), + ), + ).toBe(first); + expect(cache.size).toBe(1); + }); + + it("evicts the least-recently-used thread beyond capacity", () => { + const cache = createThreadConversationOutlineCache({ maxEntries: 2 }); + const build = vi.fn((maxSeq: number) => makeResponse(maxSeq)); + + cache.getOrBuild({ threadId: "thr_a", maxSeq: 1 }, () => build(1)); + cache.getOrBuild({ threadId: "thr_b", maxSeq: 2 }, () => build(2)); + cache.getOrBuild({ threadId: "thr_a", maxSeq: 1 }, () => build(1)); + cache.getOrBuild({ threadId: "thr_c", maxSeq: 3 }, () => build(3)); + + const buildAgain = vi.fn(() => makeResponse(4)); + cache.getOrBuild({ threadId: "thr_a", maxSeq: 1 }, buildAgain); + cache.getOrBuild({ threadId: "thr_b", maxSeq: 2 }, buildAgain); + + expect(buildAgain).toHaveBeenCalledTimes(1); + expect(cache.size).toBe(2); + }); +}); diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts index 466f7dc19..b883827c6 100644 --- a/apps/server/test/services/threads/timeline-cache.test.ts +++ b/apps/server/test/services/threads/timeline-cache.test.ts @@ -62,8 +62,8 @@ describe("createThreadTimelineCache", () => { const cache = createThreadTimelineCache(); const build = vi.fn(() => makeResponse(3)); - const first = cache.getOrBuild("k", build); - const second = cache.getOrBuild("k", build); + const first = cache.getOrBuild(baseKeyArgs, build); + const second = cache.getOrBuild(baseKeyArgs, build); expect(build).toHaveBeenCalledTimes(1); expect(second).toBe(first); @@ -74,36 +74,94 @@ describe("createThreadTimelineCache", () => { const cache = createThreadTimelineCache(); const build = vi.fn(() => makeResponse(3)); - cache.getOrBuild("k1", build); - cache.getOrBuild("k2", build); + cache.getOrBuild(baseKeyArgs, build); + cache.getOrBuild({ ...baseKeyArgs, maxSeq: 11 }, build); expect(build).toHaveBeenCalledTimes(2); + expect(cache.size).toBe(1); + }); + + it("retains only the newest revision for the same request shape", () => { + const cache = createThreadTimelineCache(); + const build = vi.fn(() => makeResponse(3)); + + for (let maxSeq = 1; maxSeq <= 128; maxSeq += 1) { + cache.getOrBuild({ ...baseKeyArgs, maxSeq }, build); + } + + expect(build).toHaveBeenCalledTimes(128); + expect(cache.size).toBe(1); }); it("does not cache responses above the row cap (streaming expanded turns)", () => { const cache = createThreadTimelineCache({ maxCacheableRows: 5 }); const build = vi.fn(() => makeResponse(50)); - cache.getOrBuild("k", build); - cache.getOrBuild("k", build); + cache.getOrBuild(baseKeyArgs, build); + cache.getOrBuild(baseKeyArgs, build); expect(build).toHaveBeenCalledTimes(2); expect(cache.size).toBe(0); }); + it("drops an obsolete revision when its replacement exceeds the row cap", () => { + const cache = createThreadTimelineCache({ maxCacheableRows: 5 }); + cache.getOrBuild(baseKeyArgs, () => makeResponse(3)); + + const buildLarge = vi.fn(() => makeResponse(50)); + const nextRevision = { ...baseKeyArgs, maxSeq: 11 }; + cache.getOrBuild(nextRevision, buildLarge); + + expect(cache.size).toBe(0); + cache.getOrBuild(nextRevision, buildLarge); + expect(buildLarge).toHaveBeenCalledTimes(2); + }); + + it("preserves the prior revision when its replacement build fails", () => { + const cache = createThreadTimelineCache(); + const first = cache.getOrBuild(baseKeyArgs, () => makeResponse(3)); + + expect(() => + cache.getOrBuild({ ...baseKeyArgs, maxSeq: 11 }, () => { + throw new Error("build failed"); + }), + ).toThrow("build failed"); + + expect(cache.getOrBuild(baseKeyArgs, () => makeResponse(4))).toBe(first); + expect(cache.size).toBe(1); + }); + + it("keeps different request shapes independently cached", () => { + const cache = createThreadTimelineCache(); + const build = vi.fn(() => makeResponse(1)); + const summaryKeyArgs = { ...baseKeyArgs, summaryOnly: true }; + + cache.getOrBuild(baseKeyArgs, build); + cache.getOrBuild(summaryKeyArgs, build); + cache.getOrBuild(baseKeyArgs, build); + cache.getOrBuild(summaryKeyArgs, build); + + expect(build).toHaveBeenCalledTimes(2); + expect(cache.size).toBe(2); + }); + it("evicts least-recently-used entries beyond maxEntries", () => { const cache = createThreadTimelineCache({ maxEntries: 2 }); const build = vi.fn(() => makeResponse(1)); + const a1 = { ...baseKeyArgs, threadId: "thr_a", maxSeq: 1 }; + const a2 = { ...a1, maxSeq: 2 }; + const b1 = { ...baseKeyArgs, threadId: "thr_b", maxSeq: 1 }; + const c1 = { ...baseKeyArgs, threadId: "thr_c", maxSeq: 1 }; - cache.getOrBuild("a", build); // [a] - cache.getOrBuild("b", build); // [a,b] - cache.getOrBuild("a", build); // touch a -> [b,a] - cache.getOrBuild("c", build); // evict b -> [a,c] + cache.getOrBuild(a1, build); // [a1] + cache.getOrBuild(b1, build); // [a1,b1] + cache.getOrBuild(a2, build); // replace a1 -> [b1,a2] + cache.getOrBuild(c1, build); // evict b1 -> [a2,c1] expect(cache.size).toBe(2); const buildAgain = vi.fn(() => makeResponse(1)); - cache.getOrBuild("a", buildAgain); // still cached - cache.getOrBuild("b", buildAgain); // evicted -> rebuild + cache.getOrBuild(a2, buildAgain); // still cached + cache.getOrBuild(b1, buildAgain); // evicted -> rebuild expect(buildAgain).toHaveBeenCalledTimes(1); }); });