Zeren Wang 5951c89b5b
feat(projects): project workspaces with scoped chats and thread membership (#5265)
* feat(projects): project workspaces with scoped chats and thread membership

Backend:
- projects table model and migration; fail-closed ProjectRepository with
  ownership checks, CRUD/archive/restore/delete router, and atomic thread
  move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
  metadata; project-aware thread create/search with pagination bounds and
  membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
  at write time and dropped when invalid; serialized against project
  deletion and thread assignment
- branch creation inherits the source thread's project membership (an
  archived/deleted project degrades the branch to unassigned instead of
  failing the request)

Frontend:
- projects data layer, thread move API, and sidebar projects section with
  flat/grouped modes, archived-project threads, and stable virtual-list
  offsets
- project detail page with project-scoped new chat
  (/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
  first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
  continuation is dropped instead of saving the goal or launching the
  abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
  an open project page never keeps stale titles, recency, or pagination

* fix(chats): keep archive undo toast when the sidebar row unmounts

The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.

Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.

* fix(projects): pin project thread listing contract and exclude archived chats

GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.

The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.

Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.

* docs(migrations): record the 0019/0020 chain against the bootstrap reservation

The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.

Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.

* fix(chats): invalidate project thread lists on archive/restore

useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.

An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.

Regression test asserts the project-list prefix is invalidated on success.

* fix(projects): fetch project discovery only in grouped sidebar mode

RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.

Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.

* fix(threads): fail closed on project-scoped create in memory mode

MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).

Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.

Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.

* fix(projects): window the project page thread list

ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.

Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.

* fix(projects): restore row dividers and pin them with a render test

The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.

The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.

* fix(projects): validate forward schemas and fence membership reads
2026-09-08 17:00:26 +08:00

518 lines
16 KiB
TypeScript

import { describe, expect, rs, test } from "@rstest/core";
import {
QueryClient,
QueryObserver,
type InfiniteData,
} 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,
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<string, unknown> = {},
): 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): AgentThread[] {
return Array.from({ length: size }, (_, i) => makeThread(`t-${start + i}`));
}
function makeInfiniteData(pages: AgentThread[][]): InfiniteData<AgentThread[]> {
return {
pages,
pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE),
};
}
describe("getInfiniteThreadsNextPageParam", () => {
test("returns next offset when the last page is full", () => {
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("returns undefined when the last page is short (end of 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);
expect(getInfiniteThreadsNextPageParam([], [page1, []])).toBeUndefined();
});
test("respects a custom page size", () => {
const page1 = makePage(0, 5);
expect(getInfiniteThreadsNextPageParam(page1, [page1], 5)).toBe(5);
expect(getInfiniteThreadsNextPageParam(page1, [page1], 10)).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.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(getInfiniteThreadsNextPageParam(page, [page], 2)).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.map((thread) => thread.thread_id)).toEqual(["sidecar-1"]);
expect(getInfiniteThreadsNextPageParam(page, [page], 2)).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]?.[0]?.values?.title).toBe("Title a");
expect(updated?.pages[1]?.[0]?.thread_id).toBe("c");
expect(updated?.pages[1]?.[0]?.values?.title).toBe("renamed");
expect(updated?.pages[1]?.[1]?.values?.title).toBe("Title d");
});
test("preserves pageParams", () => {
const data = makeInfiniteData([[makeThread("a")]]);
const updated = mapInfiniteThreadsCache(data, (t) => t);
expect(updated?.pageParams).toEqual(data.pageParams);
});
});
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]?.map((t) => t.thread_id)).toEqual(["a"]);
expect(filtered?.pages[1]?.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]).toEqual([]);
expect(filtered?.pages[1]?.[0]?.thread_id).toBe("b");
});
test("does not regress next offset when an earlier page has been shrunk by a delete", () => {
// Simulate two full pages already loaded.
const page1 = Array.from({ length: 50 }, (_, i) => ({
thread_id: `a${i}`,
}));
const page2 = Array.from({ length: 50 }, (_, i) => ({
thread_id: `b${i}`,
}));
// Offset right after fetching page 2 (this is the value TanStack Query
// freezes into pageParams).
const offsetAfterPage2 = getInfiniteThreadsNextPageParam(
page2 as unknown as AgentThread[],
[page1, page2] as unknown as AgentThread[][],
);
expect(offsetAfterPage2).toBe(100);
// Now a delete mutation runs filterInfiniteThreadsCache and shrinks
// page 1 from 50 to 49 entries. TanStack does NOT re-invoke
// getNextPageParam on cache mutations; the previously-computed offset
// (100) remains the param for the next fetchNextPage() call, so the
// helper is consistent with how the library uses its return value.
const shrunkPage1 = page1.slice(0, 49);
const recomputed = getInfiniteThreadsNextPageParam(
page2 as unknown as AgentThread[],
[shrunkPage1, page2] as unknown as AgentThread[][],
);
// We document the recomputed value for completeness, but in practice
// useDeleteThread invalidates the query in onSettled, so pages are
// refetched from offset 0 rather than relying on this number.
expect(recomputed).toBe(99);
});
});
describe("upsertThreadInInfiniteCache", () => {
function seedClient(initial?: InfiniteData<AgentThread[]>): QueryClient {
const client = new QueryClient();
if (initial) {
client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial);
}
return client;
}
function readCache(
client: QueryClient,
): InfiniteData<AgentThread[]> | 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({
pages: [[makeThread("a"), makeThread("b")]],
pageParams: [0],
});
upsertThreadInInfiniteCache(client, makeThread("new"));
const cache = readCache(client);
expect(cache?.pages[0]?.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({
pages: [[existing, makeThread("b")]],
pageParams: [0],
});
// 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]?.map((t) => t.thread_id);
expect(ids).toEqual(["a", "b"]);
expect(cache?.pages[0]?.[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<AgentThread[]>(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<AgentThread[]>(["threads", "search"])?.[0]?.values
?.title,
).toBe("New Conversation");
await stopThreadAndInvalidateCaches(
client,
() => Promise.resolve(),
"thread-1",
false,
);
await Promise.resolve();
expect(
client.getQueryData<AgentThread[]>(["threads", "search"])?.[0]?.values
?.title,
).toBe("New Conversation");
finalized = true;
await rs.advanceTimersByTimeAsync(
STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS,
);
expect(
client.getQueryData<AgentThread[]>(["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();
});