From fc436954061050c63efbc9dde5d9a66785e79392 Mon Sep 17 00:00:00 2001 From: Poxel2 Date: Thu, 17 Sep 2026 17:34:02 +0200 Subject: [PATCH] fix(web): conclusion knowledge search with target discovery and correct size pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peer page: add a clearly separated 'Search peer knowledge (conclusions)' panel querying /conclusions/query scoped to observer=peer, with an observed-peer target discovered from the conclusions the peer actually holds, plus observer→observed, session and timestamp provenance on each result. Message search is unchanged. - useConclusionTargetPeers: walk conclusions/list (paginated) with an observer_id filter and collect distinct observed_id values with row counts. Returns { targets, complete }; complete is false when the walk hit the page cap, so a truncated result is never reported as complete and a single target from a truncated walk is not auto-selected. - PeerDetail: target setKnowledgeQuery(e.target.value)} + placeholder="Search this peer's conclusions…" + className="flex-1 text-sm min-w-48" + /> + + + {activeKnowledgeQuery && ( + + )} + + + + {activeKnowledgeQuery && ( + + {knowledgeLoading ? ( + + ) : !knowledgeTarget ? ( + + Pick an observed peer above — targets are listed from the conclusions this + peer actually holds. + + ) : !Array.isArray(knowledgeResults) || + (knowledgeResults as components["schemas"]["Conclusion"][]).length === 0 ? ( + No conclusions found for this scope. + ) : ( + /* Display-only dedup: identical contents repeat heavily in the + store (bulk import). Collapse them here — the database is NOT + touched (no data lane in this repair). First occurrence wins; + provenance (observer→observed, session, timestamp) is kept + from that first row. */ + (() => { + const all = knowledgeResults as components["schemas"]["Conclusion"][]; + const seenContent = new Set(); + const deduped = all.filter((c) => { + if (seenContent.has(c.content)) return false; + seenContent.add(c.content); + return true; + }); + const hidden = all.length - deduped.length; + return ( + <> + {deduped.map((c) => ( +
+
+ + {mask(c.observer_id)} → {mask(c.observed_id)} + + {c.session_id && session: {mask(c.session_id)}} + {c.created_at && ( + {new Date(c.created_at).toLocaleString()} + )} +
+ {mask(c.content)} +
+ ))} + {hidden > 0 && ( + + {deduped.length} unique result{deduped.length === 1 ? "" : "s"} ( + {hidden} identical duplicate{hidden === 1 ? "" : "s"} hidden — + display only, no data changed) + + )} + + ); + })() + )} +
+ )} +
+ + {/* Card + Representation — side by side */}
{/* Peer Card */} diff --git a/packages/web/src/components/seed-kits/ApplyKitDialog.tsx b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx index 245b488..04e7d93 100644 --- a/packages/web/src/components/seed-kits/ApplyKitDialog.tsx +++ b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx @@ -33,7 +33,7 @@ function useScopedWorkspacesAll(instance: Instance | null) { if (!instance) return [] as Array<{ id: string }>; const client = createScopedClient(instance); const { data, error } = await client.POST("/v3/workspaces/list", { - params: { query: { page: 1, page_size: 100 } }, + params: { query: { page: 1, size: 100 } }, body: {}, }); const payload = data ?? err(error); diff --git a/packages/web/src/lib/discovery.ts b/packages/web/src/lib/discovery.ts index 6784f80..c5b4935 100644 --- a/packages/web/src/lib/discovery.ts +++ b/packages/web/src/lib/discovery.ts @@ -38,7 +38,7 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string { export async function suggestNameForInstance(baseUrl: string): Promise { try { const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl }); - const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, { + const res = await fetch(`${base}/v3/workspaces/list?page=1&size=1`, { method: "POST", headers, body: JSON.stringify({}), diff --git a/packages/web/src/test/conclusion-targets.test.tsx b/packages/web/src/test/conclusion-targets.test.tsx new file mode 100644 index 0000000..bae19f7 --- /dev/null +++ b/packages/web/src/test/conclusion-targets.test.tsx @@ -0,0 +1,197 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useConclusionTargetPeers } from "@/api/queries"; + +const post = vi.hoisted(() => vi.fn()); +vi.mock("@/api/client", () => ({ + client: { current: { POST: post } }, +})); +vi.mock("@/api/scopedClient", () => ({ createScopedClient: vi.fn() })); + +function wrap(qc: QueryClient) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +type ListResponse = { + items: Array<{ observer_id: string | null; observed_id: string | null }>; + pages: number; +}; + +function listPage(items: ListResponse["items"], pages: number): ListResponse { + return { items, pages }; +} + +function conclusion(observer: string | null, observed: string | null) { + return { observer_id: observer, observed_id: observed }; +} + +beforeEach(() => { + post.mockReset(); + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe("useConclusionTargetPeers", () => { + it("collects distinct observed targets with row counts from a complete walk", async () => { + post + .mockResolvedValueOnce({ + data: listPage( + [ + conclusion("observer-a", "target-1"), + conclusion("observer-a", "target-1"), + conclusion("observer-a", "target-2"), + ], + 1, + ), + }) + .mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data).toEqual({ + targets: [ + { id: "target-1", count: 2 }, + { id: "target-2", count: 1 }, + ], + complete: true, + }); + }); + + it("walks multiple pages until the last page and reports complete", async () => { + post + .mockResolvedValueOnce({ + data: listPage([conclusion("observer-a", "target-1")], 2), + }) + .mockResolvedValueOnce({ + data: listPage([conclusion("observer-a", "target-2")], 2), + }) + .mockResolvedValue({ data: listPage([], 2) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(true); + expect(result.current.data?.targets).toHaveLength(2); + }); + + it("reports complete=false when the store exceeds the page cap", async () => { + // Every page reports more pages ahead; the walk must stop at the cap + // and must NOT report the truncated result as complete. + post.mockImplementation(async (_url: string, opts: { params: { query: { page: number } } }) => { + const page = opts.params.query.page; + if (page <= 40) { + return { data: listPage([conclusion("observer-a", "target-1")], page + 1) }; + } + throw new Error("walk should have stopped at the page cap"); + }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(false); + expect(result.current.data?.targets).toHaveLength(1); + expect(post).toHaveBeenCalledTimes(40); + }); + + it("reports complete=false at the page-41 boundary (pages == cap + 1)", async () => { + // Regression: with the server reporting exactly MAX_PAGES + 1 pages, the + // walk exits the loop with page === MAX_PAGES + 1. Recomputing + // completeness as `page >= pages` after the loop compared 41 >= 41 and + // wrongly reported a cap-truncated walk as complete. Completeness must + // come from the break reason (normal page exhaustion) instead. + post.mockImplementation(async () => ({ + data: listPage([conclusion("observer-a", "target-1")], 41), + })); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.complete).toBe(false); + expect(post).toHaveBeenCalledTimes(40); + }); + + it("reports complete=true when the last walked page is exactly the reported page count", async () => { + // pages == cap: the walk finishes on the final allowed page and the + // result is genuinely complete (regression for the off-by-one where a + // cap-truncated walk compared page > pages after the loop). + let page = 0; + post.mockImplementation(async () => { + page += 1; + return { data: listPage([conclusion("observer-a", "target-1")], 40) }; + }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(page).toBe(40); + expect(result.current.data?.complete).toBe(true); + }); + + it("surfaces errors instead of swallowing them", async () => { + post.mockResolvedValue({ error: { message: "boom" } }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + }); + + it("returns an empty complete target list for an observer with no conclusions", async () => { + post.mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data).toEqual({ targets: [], complete: true }); + }); + + it("sorts targets by descending count, then id", async () => { + post + .mockResolvedValueOnce({ + data: listPage( + [ + conclusion("observer-a", "b-target"), + conclusion("observer-a", "a-target"), + conclusion("observer-a", "a-target"), + ], + 1, + ), + }) + .mockResolvedValue({ data: listPage([], 1) }); + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useConclusionTargetPeers("ws", "observer-a"), { + wrapper: wrap(qc), + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.targets.map((t) => t.id)).toEqual(["a-target", "b-target"]); + }); +});