import { describe, expect, rs, test } from "@rstest/core"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { PROJECTS_QUERY_KEY } from "@/core/projects/api"; import { fetchInfiniteThreadsPage, filterInfiniteThreadsCache, getInfiniteThreadsNextPageParam, INFINITE_THREADS_PAGE_SIZE, INFINITE_THREADS_QUERY_KEY_PREFIX, type InfiniteThreadsData, type InfiniteThreadsPage, invalidateStoppedThreadCaches, mapInfiniteThreadsCache, STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS, stopThreadAndInvalidateCaches, upsertThreadInInfiniteCache, } from "@/core/threads/hooks"; import type { AgentThread } from "@/core/threads/types"; // Issue #3482: the sidebar and /workspace/chats list used to be capped at // 50 threads because `useThreads()` exits as soon as `threads.length >= // params.limit`. These pure helpers back the `useInfiniteThreads()` // pagination logic and the mirrored cache writes that keep rename / delete // / stream-finish in sync with both the legacy array cache and the new // infinite cache. function makeThread( id: string, title = `Title ${id}`, metadata: Record = {}, ): AgentThread { return { thread_id: id, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", metadata, status: "idle", values: { title }, } as unknown as AgentThread; } function makePage( start: number, size: number, { hiddenCount = 0, hasMore = size === INFINITE_THREADS_PAGE_SIZE, }: { hiddenCount?: number; hasMore?: boolean } = {}, ): InfiniteThreadsPage { return { threads: Array.from({ length: size }, (_, i) => makeThread(`t-${start + i}`), ), hiddenCount, hasMore, }; } function makeInfiniteData(pages: AgentThread[][]): InfiniteThreadsData { return { pages: pages.map((threads) => ({ threads, hiddenCount: 0, hasMore: true })), pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE), }; } describe("getInfiniteThreadsNextPageParam", () => { test("returns next offset when the last page has more rows", () => { const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); expect(getInfiniteThreadsNextPageParam(page1, [page1])).toBe( INFINITE_THREADS_PAGE_SIZE, ); }); test("returns next offset across multiple full pages", () => { const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); const page2 = makePage( INFINITE_THREADS_PAGE_SIZE, INFINITE_THREADS_PAGE_SIZE, ); expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe( INFINITE_THREADS_PAGE_SIZE * 2, ); }); test("advances past server rows that pages hid", () => { const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 }); const page2 = makePage(53, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 1 }); expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe( INFINITE_THREADS_PAGE_SIZE * 2 + 4, ); }); test("returns undefined when the last page reached the end of the list", () => { const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 10); expect( getInfiniteThreadsNextPageParam(page2, [page1, page2]), ).toBeUndefined(); }); test("returns undefined when the last page is empty", () => { const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 0); expect( getInfiniteThreadsNextPageParam(page2, [page1, page2]), ).toBeUndefined(); }); test("reads the end of the list from the page, not its length", () => { const shrunkByDelete = makePage(0, INFINITE_THREADS_PAGE_SIZE - 1, { hasMore: true, }); expect( getInfiniteThreadsNextPageParam(shrunkByDelete, [shrunkByDelete]), ).toBe(INFINITE_THREADS_PAGE_SIZE - 1); const grownByInsert = makePage(0, INFINITE_THREADS_PAGE_SIZE, { hasMore: false, }); expect( getInfiniteThreadsNextPageParam(grownByInsert, [grownByInsert]), ).toBeUndefined(); }); }); describe("fetchInfiniteThreadsPage", () => { test("fills a visible page while advancing offsets by raw backend rows", async () => { const search = rs .fn() .mockResolvedValueOnce([ makeThread("sidecar-1", "Sidecar", { deerflow_sidecar: true }), makeThread("primary-1"), ]) .mockResolvedValueOnce([makeThread("primary-2")]); const page = await fetchInfiniteThreadsPage( { threads: { search } }, { sortBy: "updated_at", sortOrder: "desc" }, 0, 2, ); expect(page.threads.map((thread) => thread.thread_id)).toEqual([ "primary-1", "primary-2", ]); expect(search).toHaveBeenNthCalledWith(1, { sortBy: "updated_at", sortOrder: "desc", limit: 2, offset: 0, }); expect(search).toHaveBeenNthCalledWith(2, { sortBy: "updated_at", sortOrder: "desc", limit: 1, offset: 2, }); expect(page).toMatchObject({ hiddenCount: 1, hasMore: true }); expect(getInfiniteThreadsNextPageParam(page, [page])).toBe(3); }); test("keeps sidecar rows when the caller explicitly searches for sidecars", async () => { const search = rs.fn().mockResolvedValueOnce([ makeThread("sidecar-1", "Sidecar", { deerflow_sidecar: true, parent_thread_id: "parent-1", }), ]); const page = await fetchInfiniteThreadsPage( { threads: { search } }, { sortBy: "updated_at", sortOrder: "desc", metadata: { deerflow_sidecar: true, parent_thread_id: "parent-1" }, }, 0, 2, ); expect(page.threads.map((thread) => thread.thread_id)).toEqual([ "sidecar-1", ]); expect(page).toMatchObject({ hiddenCount: 0, hasMore: false }); expect(getInfiniteThreadsNextPageParam(page, [page])).toBeUndefined(); }); }); describe("mapInfiniteThreadsCache", () => { test("returns undefined when oldData is undefined", () => { expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined(); }); test("updates the matching thread across multiple pages", () => { const page1 = [makeThread("a"), makeThread("b")]; const page2 = [makeThread("c"), makeThread("d")]; const data = makeInfiniteData([page1, page2]); const updated = mapInfiniteThreadsCache(data, (t) => t.thread_id === "c" ? { ...t, values: { ...t.values, title: "renamed" } } : t, ); expect(updated?.pages[0]?.threads[0]?.values?.title).toBe("Title a"); expect(updated?.pages[1]?.threads[0]?.thread_id).toBe("c"); expect(updated?.pages[1]?.threads[0]?.values?.title).toBe("renamed"); expect(updated?.pages[1]?.threads[1]?.values?.title).toBe("Title d"); }); test("preserves pageParams and the pagination state of each page", () => { const data: InfiniteThreadsData = { pages: [makePage(0, 2, { hiddenCount: 3, hasMore: false })], pageParams: [0], }; const updated = mapInfiniteThreadsCache(data, (t) => t); expect(updated?.pageParams).toEqual(data.pageParams); expect(updated?.pages[0]).toMatchObject({ hiddenCount: 3, hasMore: false }); }); }); describe("filterInfiniteThreadsCache", () => { test("returns undefined when oldData is undefined", () => { expect(filterInfiniteThreadsCache(undefined, () => true)).toBeUndefined(); }); test("removes matching threads across all pages", () => { const page1 = [makeThread("a"), makeThread("b")]; const page2 = [makeThread("b"), makeThread("c")]; const data = makeInfiniteData([page1, page2]); const filtered = filterInfiniteThreadsCache( data, (t) => t.thread_id !== "b", ); expect(filtered?.pages[0]?.threads.map((t) => t.thread_id)).toEqual(["a"]); expect(filtered?.pages[1]?.threads.map((t) => t.thread_id)).toEqual(["c"]); }); test("keeps an emptied page as an empty array (does not drop the page)", () => { const page1 = [makeThread("a")]; const page2 = [makeThread("b")]; const data = makeInfiniteData([page1, page2]); const filtered = filterInfiniteThreadsCache( data, (t) => t.thread_id !== "a", ); expect(filtered?.pages).toHaveLength(2); expect(filtered?.pages[0]?.threads).toEqual([]); expect(filtered?.pages[1]?.threads[0]?.thread_id).toBe("b"); }); test("moves the next offset back with a delete and keeps hidden rows", () => { const data: InfiniteThreadsData = { pages: [ makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 }), makePage(53, INFINITE_THREADS_PAGE_SIZE), ], pageParams: [0, 53], }; // The deleted thread also leaves the server list, so the next server // offset moves back by one; the hidden sidecar rows still count. const filtered = filterInfiniteThreadsCache( data, (t) => t.thread_id !== "t-0", ); const pages = filtered?.pages ?? []; expect(getInfiniteThreadsNextPageParam(pages.at(-1)!, pages)).toBe(102); }); }); describe("upsertThreadInInfiniteCache", () => { function seedClient(initial?: InfiniteThreadsData): QueryClient { const client = new QueryClient(); if (initial) { client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial); } return client; } function readCache(client: QueryClient): InfiniteThreadsData | undefined { return client.getQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}]); } test("no-op when the infinite cache has not been initialised yet", () => { const client = seedClient(); upsertThreadInInfiniteCache(client, makeThread("new")); expect(readCache(client)).toBeUndefined(); }); test("prepends a brand-new thread to the first page", () => { const client = seedClient( makeInfiniteData([[makeThread("a"), makeThread("b")]]), ); upsertThreadInInfiniteCache(client, makeThread("new")); const cache = readCache(client); expect(cache?.pages[0]?.threads.map((t) => t.thread_id)).toEqual([ "new", "a", "b", ]); }); test("merges into the existing entry instead of duplicating it", () => { const existing = makeThread("a", "Old title"); const client = seedClient(makeInfiniteData([[existing, makeThread("b")]])); // Simulate an onCreated upsert that races with a thread already in cache: // the cache copy should win for title/metadata (it represents later state), // but no duplicate row should appear. upsertThreadInInfiniteCache(client, { ...makeThread("a", "New title"), status: "busy", }); const cache = readCache(client); const ids = cache?.pages[0]?.threads.map((t) => t.thread_id); expect(ids).toEqual(["a", "b"]); expect(cache?.pages[0]?.threads[0]?.values.title).toBe("Old title"); }); }); describe("invalidateStoppedThreadCaches", () => { function invalidatedQueryKeys(client: QueryClient) { const invalidate = rs.spyOn(client, "invalidateQueries"); return { invalidate, queryKeys: () => invalidate.mock.calls.map(([filters]) => filters?.queryKey), }; } test("refreshes current thread and sidebar caches after fire-and-forget stop", () => { const client = new QueryClient(); const { queryKeys } = invalidatedQueryKeys(client); invalidateStoppedThreadCaches(client, "thread-1", false); expect(queryKeys()).toContainEqual(["threads", "search"]); expect(queryKeys()).toContainEqual(INFINITE_THREADS_QUERY_KEY_PREFIX); expect(queryKeys()).toContainEqual(["thread", "thread-1"]); expect(queryKeys()).toContainEqual([ "thread", "metadata", "thread-1", false, ]); expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]); }); test("preserves loaded history pages while invalidating", () => { const client = new QueryClient(); const key = ["thread-messages", "thread-1"] as const; const latest = { data: [], has_more: true, next_before_seq: 20 }; const older = { data: [], has_more: false, next_before_seq: null }; client.setQueryData(key, { pages: [latest, older], pageParams: [null, 20], }); invalidateStoppedThreadCaches(client, "thread-1", false); expect(client.getQueryData(key)).toEqual({ pages: [latest, older], pageParams: [null, 20], }); }); test("does not refresh per-thread API caches for mock threads", () => { const client = new QueryClient(); const { queryKeys } = invalidatedQueryKeys(client); invalidateStoppedThreadCaches(client, "thread-1", true); expect(queryKeys()).toContainEqual(["threads", "search"]); expect(queryKeys()).toContainEqual(INFINITE_THREADS_QUERY_KEY_PREFIX); expect(queryKeys()).not.toContainEqual(["thread", "thread-1"]); expect(queryKeys()).not.toContainEqual([ "thread", "metadata", "thread-1", true, ]); expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]); }); test("wraps SDK stop and refreshes caches after it resolves", async () => { const client = new QueryClient(); const stop = rs.fn(() => Promise.resolve()); const { queryKeys } = invalidatedQueryKeys(client); await stopThreadAndInvalidateCaches(client, stop, "thread-1", false); expect(stop).toHaveBeenCalledTimes(1); expect(queryKeys()).toContainEqual([ "thread", "metadata", "thread-1", false, ]); }); test("still refreshes caches when SDK stop rejects", async () => { const client = new QueryClient(); const stop = rs.fn(async () => { throw new Error("cancel failed"); }); const { queryKeys } = invalidatedQueryKeys(client); await expect( stopThreadAndInvalidateCaches(client, stop, "thread-1", false), ).rejects.toThrow("cancel failed"); expect(queryKeys()).toContainEqual(["threads", "search"]); expect(queryKeys()).toContainEqual([ "thread", "metadata", "thread-1", false, ]); }); test("schedules sidebar refetch even if stopped thread id is not known", async () => { rs.useFakeTimers(); const client = new QueryClient(); const { queryKeys } = invalidatedQueryKeys(client); try { await stopThreadAndInvalidateCaches( client, () => Promise.resolve(), null, false, ); const countSearchInvalidations = () => queryKeys().filter( (queryKey) => queryKey?.length === 2 && queryKey[0] === "threads" && queryKey[1] === "search", ).length; expect(countSearchInvalidations()).toBe(1); await rs.advanceTimersByTimeAsync( STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS, ); expect(countSearchInvalidations()).toBe(2); expect(queryKeys()).not.toContainEqual(["thread", null]); } finally { client.clear(); rs.useRealTimers(); } }); test("scheduled refetch lets sidebar receive delayed backend title finalization", async () => { rs.useFakeTimers(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); let finalized = false; let fetchCount = 0; const observer = new QueryObserver(client, { queryKey: ["threads", "search"], queryFn: async () => { fetchCount += 1; return [ makeThread( "thread-1", finalized ? "Generated Title" : "New Conversation", ), ]; }, }); const unsubscribe = observer.subscribe((result) => { void result.status; }); try { await observer.refetch(); expect( client.getQueryData(["threads", "search"])?.[0]?.values ?.title, ).toBe("New Conversation"); await stopThreadAndInvalidateCaches( client, () => Promise.resolve(), "thread-1", false, ); await Promise.resolve(); expect( client.getQueryData(["threads", "search"])?.[0]?.values ?.title, ).toBe("New Conversation"); finalized = true; await rs.advanceTimersByTimeAsync( STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS, ); expect( client.getQueryData(["threads", "search"])?.[0]?.values ?.title, ).toBe("Generated Title"); expect(fetchCount).toBeGreaterThanOrEqual(3); } finally { unsubscribe(); client.clear(); rs.useRealTimers(); } }); }); test("run-created snapshots without archive metadata cannot insert into filtered lists", () => { const client = new QueryClient(); const recentKey = [...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }]; const archivedKey = [ ...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: true }, ]; const empty = makeInfiniteData([[]]); client.setQueryData(recentKey, empty); client.setQueryData(archivedKey, empty); upsertThreadInInfiniteCache(client, makeThread("running-thread")); expect(client.getQueryData(recentKey)).toEqual(empty); expect(client.getQueryData(archivedKey)).toEqual(empty); expect(client.getQueryState(recentKey)?.isInvalidated).toBe(true); expect(client.getQueryState(archivedKey)?.isInvalidated).toBe(true); client.clear(); });