From 3dbf7493a2ea5ae6246f683da6dc2f4375dd0aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 21 Sep 2026 12:04:19 +0800 Subject: [PATCH] fix(desktop): query session suggestions directly from SQLite --- desktop/src/api/filesystem.test.ts | 12 +++ desktop/src/api/filesystem.ts | 10 +-- desktop/src/api/sessionCollaboration.test.ts | 5 +- desktop/src/api/sessionCollaboration.ts | 4 +- .../src/components/chat/ChatInput.test.tsx | 2 +- .../chat/ComposerCapabilityMenu.test.tsx | 2 +- .../chat/ComposerReferenceMenu.test.tsx | 50 ++++++++++++- .../components/chat/ComposerReferenceMenu.tsx | 28 ++++--- desktop/src/pages/EmptySession.test.tsx | 2 +- src/server/api/sessionCollaboration.test.ts | 17 +++++ src/server/api/sessionCollaboration.ts | 4 +- src/server/services/localIndex/coordinator.ts | 12 +++ .../searchContentCoordinator.test.ts | 5 ++ .../localIndex/searchContentCoordinator.ts | 13 ++++ .../localIndex/searchContentIndex.test.ts | 72 ++++++++++++++++++ .../services/localIndex/searchContentIndex.ts | 45 ++++++++++++ .../services/localIndex/sessionIndex.ts | 42 +++++++++++ .../localIndex/sessionMetadataSearch.test.ts | 73 +++++++++++++++++++ .../searchService.suggestions.test.ts | 26 +++++++ src/server/services/searchService.ts | 19 +++++ .../services/sessionCollaborationHost.test.ts | 73 +++++++++++-------- .../services/sessionCollaborationHost.ts | 71 ++++++++---------- .../sessionCollaborationService.test.ts | 22 ++++++ .../services/sessionCollaborationService.ts | 21 ++++-- .../services/sessionMetadataSearch.test.ts | 68 +++++++++++++++++ src/server/services/sessionService.ts | 48 ++++++++++++ 26 files changed, 636 insertions(+), 110 deletions(-) create mode 100644 desktop/src/api/filesystem.test.ts create mode 100644 src/server/services/localIndex/sessionMetadataSearch.test.ts create mode 100644 src/server/services/searchService.suggestions.test.ts create mode 100644 src/server/services/sessionMetadataSearch.test.ts diff --git a/desktop/src/api/filesystem.test.ts b/desktop/src/api/filesystem.test.ts new file mode 100644 index 00000000..18edb8fd --- /dev/null +++ b/desktop/src/api/filesystem.test.ts @@ -0,0 +1,12 @@ +import { expect, it, vi } from 'vitest' +import { api } from '@/api/client' +import { filesystemApi } from '@/api/filesystem' +vi.mock('@/api/client', () => ({ api: { get: vi.fn() } })) + +it('forwards cancellation for keyword searches and directory browsing', () => { + const signal = new AbortController().signal + filesystemApi.search('修复', '/work', { signal }) + filesystemApi.browse('/work/src', { includeFiles: true, signal }) + expect(api.get).toHaveBeenCalledWith('/api/filesystem/browse?search=%E4%BF%AE%E5%A4%8D&maxResults=200&includeFiles=true&path=%2Fwork', { signal }) + expect(api.get).toHaveBeenCalledWith('/api/filesystem/browse?path=%2Fwork%2Fsrc&includeFiles=true', { includeFiles: true, signal }) +}) diff --git a/desktop/src/api/filesystem.ts b/desktop/src/api/filesystem.ts index 30883ff9..b39ebfc2 100644 --- a/desktop/src/api/filesystem.ts +++ b/desktop/src/api/filesystem.ts @@ -1,4 +1,4 @@ -import { api } from './client' +import { api, type ApiRequestOptions } from './client' type DirEntry = { name: string @@ -15,17 +15,17 @@ type BrowseResult = { } export const filesystemApi = { - browse(path?: string, options?: { includeFiles?: boolean }) { + browse(path?: string, options?: { includeFiles?: boolean } & ApiRequestOptions) { const q = new URLSearchParams() if (path) q.set('path', path) if (options?.includeFiles) q.set('includeFiles', 'true') const qs = q.toString() - return api.get(`/api/filesystem/browse${qs ? `?${qs}` : ''}`) + return api.get(`/api/filesystem/browse${qs ? `?${qs}` : ''}`, options) }, - search(query: string, cwd?: string) { + search(query: string, cwd?: string, options?: ApiRequestOptions) { const q = new URLSearchParams({ search: query, maxResults: '200', includeFiles: 'true' }) if (cwd) q.set('path', cwd) - return api.get(`/api/filesystem/browse?${q}`) + return api.get(`/api/filesystem/browse?${q}`, options) }, } diff --git a/desktop/src/api/sessionCollaboration.test.ts b/desktop/src/api/sessionCollaboration.test.ts index 51746e55..fcf73d22 100644 --- a/desktop/src/api/sessionCollaboration.test.ts +++ b/desktop/src/api/sessionCollaboration.test.ts @@ -3,10 +3,11 @@ import { api } from '@/api/client' import { sessionCollaborationApi } from './sessionCollaboration' vi.mock('@/api/client', () => ({ api: { get: vi.fn(), post: vi.fn() } })) it('uses the authenticated API client and encodes session identifiers and search text', () => { - sessionCollaborationApi.list('Auth / 中文') + const signal = new AbortController().signal + sessionCollaborationApi.list('Auth / 中文', { signal }) sessionCollaborationApi.status('session/a') sessionCollaborationApi.stop('session/a') - expect(api.get).toHaveBeenCalledWith('/api/session-collaboration?query=Auth%20%2F%20%E4%B8%AD%E6%96%87') + expect(api.get).toHaveBeenCalledWith('/api/session-collaboration?query=Auth%20%2F%20%E4%B8%AD%E6%96%87', { signal }) expect(api.get).toHaveBeenCalledWith('/api/session-collaboration/session%2Fa/status') expect(api.post).toHaveBeenCalledWith('/api/session-collaboration/session%2Fa/stop', {}) }) diff --git a/desktop/src/api/sessionCollaboration.ts b/desktop/src/api/sessionCollaboration.ts index efd6e1af..523913b5 100644 --- a/desktop/src/api/sessionCollaboration.ts +++ b/desktop/src/api/sessionCollaboration.ts @@ -1,4 +1,4 @@ -import { api } from '@/api/client' +import { api, type ApiRequestOptions } from '@/api/client' export type SessionCandidate = { sessionId: string @@ -15,7 +15,7 @@ export type SessionCollaborationStatus = { } const base = '/api/session-collaboration' export const sessionCollaborationApi = { - list(query = '') { return api.get<{ sessions: SessionCandidate[] }>(`${base}?query=${encodeURIComponent(query)}`) }, + list(query = '', options?: ApiRequestOptions) { return api.get<{ sessions: SessionCandidate[] }>(`${base}?query=${encodeURIComponent(query)}`, options) }, status(sessionId: string) { return api.get(`${base}/${encodeURIComponent(sessionId)}/status`) }, stop(sessionId: string) { return api.post(`${base}/${encodeURIComponent(sessionId)}/stop`, {}) }, } diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index cf901852..0dae8772 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -1961,7 +1961,7 @@ describe('ChatInput file mentions', () => { }) expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md') expect(getComposerText()).toContain('Please review @README.md') - expect(mocks.search).toHaveBeenCalledWith('README', '/repo') + expect(mocks.search).toHaveBeenCalledWith('README', '/repo', { signal: expect.any(AbortSignal) }) expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument() expect(mocks.wsSend).not.toHaveBeenCalled() fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) diff --git a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx index 8ac1e8dd..e2e396a0 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx @@ -156,7 +156,7 @@ describe('ComposerCapabilityMenu', () => { fireEvent.change(searchInput(), { target: { value: 'no-such-capability' } }) expect(await screen.findByText('No matching references')).toBeInTheDocument() - expect(sessionCollaborationApi.list).toHaveBeenCalledWith('no-such-capability') + expect(sessionCollaborationApi.list).toHaveBeenCalledWith('no-such-capability', expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) }) diff --git a/desktop/src/components/chat/ComposerReferenceMenu.test.tsx b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx index 34e9d030..fe5a3a62 100644 --- a/desktop/src/components/chat/ComposerReferenceMenu.test.tsx +++ b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx @@ -80,7 +80,7 @@ it('browses directory queries without filtering out their children', async () => const onSelect = vi.fn() render() await screen.findByRole('option', { name: 'app.ts' }) - expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true }) + expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true, signal: expect.any(AbortSignal) }) expect(screen.queryByRole('option', { name: 'Design' })).not.toBeInTheDocument() }) @@ -90,6 +90,7 @@ it('discards late query and workspace results and never selects stale files', as const onSelect = vi.fn() const ref = createRef() const view = render() + await waitFor(() => expect(filesystemApi.search).toHaveBeenCalled()) view.rerender() act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) }) expect(onSelect).not.toHaveBeenCalled() @@ -118,6 +119,7 @@ it('preserves explicitly highlighted results when asynchronous files change thei const onSelect = vi.fn() render() fireEvent.mouseEnter(screen.getByRole('option', { name: 'HyperFrames' })) + await waitFor(() => expect(filesystemApi.search).toHaveBeenCalled()) await act(async () => { resolveFiles({ currentPath: '/work', parentPath: '/', entries: [{ ...file, name: 'hyper', path: '/work/hyper' }] }) }) expect(screen.getAllByRole('option')[0]).toHaveAccessibleName('hyper') expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAttribute('aria-selected', 'true') @@ -138,7 +140,7 @@ it.each(['src/', 'src\\'])('keeps every child accessible while browsing %s', asy render() await screen.findByRole('option', { name: 'file-14.ts' }) expect(screen.getAllByRole('option')).toHaveLength(15) - expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true }) + expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true, signal: expect.any(AbortSignal) }) expect(filesystemApi.search).not.toHaveBeenCalled() }) @@ -155,7 +157,7 @@ it('searches previous sessions and selects a structured reference without treati const onSelect = vi.fn() render() fireEvent.click(await screen.findByRole('option', { name: 'Auth review' })) - expect(sessionCollaborationApi.list).toHaveBeenCalledWith('Auth') + expect(sessionCollaborationApi.list).toHaveBeenCalledWith('Auth', expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: 'session', id: 'past-session', label: 'Auth review', path: '' })) }) @@ -206,7 +208,7 @@ it('discards stale session results and keeps colliding ID prefixes distinguishab const ref = createRef() const onSelect = vi.fn() const view = render() - await waitFor(() => expect(sessionCollaborationApi.list).toHaveBeenCalledWith('old')) + await waitFor(() => expect(sessionCollaborationApi.list).toHaveBeenCalledWith('old', expect.objectContaining({ signal: expect.any(AbortSignal) }))) const sessions = ['same1234-A', 'same1234-B'].map(sessionId => ({ sessionId, title: 'Review', cwd: '/work/api', status: 'idle', updatedAt: '' })) vi.mocked(sessionCollaborationApi.list).mockResolvedValue({ sessions }) view.rerender() @@ -218,3 +220,43 @@ it('discards stale session results and keeps colliding ID prefixes distinguishab act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); }) expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'same1234-B' })) }) + +it('debounces keyword requests and aborts superseded and unmounted requests', async () => { + vi.useFakeTimers() + vi.mocked(filesystemApi.search).mockImplementation(() => new Promise(() => {})) + vi.mocked(sessionCollaborationApi.list).mockImplementation(() => new Promise(() => {})) + try { + const view = render() + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + view.rerender() + await act(async () => { await vi.advanceTimersByTimeAsync(149) }) + expect(filesystemApi.search).not.toHaveBeenCalled() + expect(sessionCollaborationApi.list).not.toHaveBeenCalled() + await act(async () => { await vi.advanceTimersByTimeAsync(1) }) + expect(filesystemApi.search).toHaveBeenCalledTimes(1) + expect(sessionCollaborationApi.list).toHaveBeenCalledTimes(1) + const fileSignal = vi.mocked(filesystemApi.search).mock.calls[0]![2]!.signal! + const sessionSignal = vi.mocked(sessionCollaborationApi.list).mock.calls[0]![1]!.signal! + expect(fileSignal.aborted).toBe(false) + expect(sessionSignal.aborted).toBe(false) + view.rerender() + expect(fileSignal.aborted).toBe(true) + expect(sessionSignal.aborted).toBe(true) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + const nextFileSignal = vi.mocked(filesystemApi.search).mock.calls[1]![2]!.signal! + const nextSessionSignal = vi.mocked(sessionCollaborationApi.list).mock.calls[1]![1]!.signal! + view.unmount() + expect(nextFileSignal.aborted).toBe(true) + expect(nextSessionSignal.aborted).toBe(true) + } finally { vi.useRealTimers() } +}) + +it('browses directories immediately and cancels their request on workspace change', () => { + vi.mocked(filesystemApi.browse).mockImplementation(() => new Promise(() => {})) + const view = render() + expect(filesystemApi.browse).toHaveBeenCalledTimes(1) + const signal = vi.mocked(filesystemApi.browse).mock.calls[0]![1]!.signal! + view.rerender() + expect(signal.aborted).toBe(true) + expect(filesystemApi.browse).toHaveBeenCalledTimes(2) +}) diff --git a/desktop/src/components/chat/ComposerReferenceMenu.tsx b/desktop/src/components/chat/ComposerReferenceMenu.tsx index 74e261fe..3c49d367 100644 --- a/desktop/src/components/chat/ComposerReferenceMenu.tsx +++ b/desktop/src/components/chat/ComposerReferenceMenu.tsx @@ -44,14 +44,15 @@ export const ComposerReferenceMenu = forwardRef { if (browseReferences) return let active = true + const controller = new AbortController() const timer = setTimeout(() => { - void sessionCollaborationApi.list(filter).then(data => { + void sessionCollaborationApi.list(filter, { signal: controller.signal }).then(data => { if (active) setSessionResult({ query: filter, sessions: data.sessions }) }, () => { if (active) setSessionResult({ query: filter, sessions: [], error: true }) }) }, 150) - return () => { active = false; clearTimeout(timer) } + return () => { active = false; clearTimeout(timer); controller.abort() } }, [filter, browseReferences]) const sessionCandidates = !browseReferences && sessionResult?.query === filter ? sessionResult.sessions : [] const sessionLoading = !browseReferences && sessionResult?.query !== filter @@ -73,18 +74,23 @@ export const ComposerReferenceMenu = forwardRef { if (browseReferences || !filter.trim() && override === undefined) return let active = true + const controller = new AbortController() const base = (cwd || rootRef.current.path).replace(/[\\/]+$/, '') const path = override ?? (browsingDirectory && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base) const search = override || browsingDirectory ? '' : directoryQuery - const request = search ? filesystemApi.search(search, path) : filesystemApi.browse(path, { includeFiles: true }) - void request.then(data => { - if (!active) return - if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath } - setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path }) - }, error => { - if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' }) - }) - return () => { active = false } + const run = () => { + const request = search ? filesystemApi.search(search, path, { signal: controller.signal }) : filesystemApi.browse(path, { includeFiles: true, signal: controller.signal }) + void request.then(data => { + if (!active) return + if (!rootRef.current.path) rootRef.current = { cwd, path: data.currentPath } + setResult({ key: queryKey, entries: data.entries, current: data.currentPath, root: rootRef.current.path }) + }, error => { + if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' }) + }) + } + const timer = search ? setTimeout(run, 150) : undefined + if (!search) run() + return () => { active = false; clearTimeout(timer); controller.abort() } }, [cwd, filter, override, queryKey, browseReferences, directoryQuery, browsingDirectory]) const groups = useMemo(() => { diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 50f55af8..8098cd7a 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -419,7 +419,7 @@ describe('EmptySession', () => { }) expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md') expect(getComposerText()).toContain('Please review @README.md') - expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project') + expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project', { signal: expect.any(AbortSignal) }) expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument() expect(mocks.wsSend).not.toHaveBeenCalled() expect(mocks.createSession).not.toHaveBeenCalled() diff --git a/src/server/api/sessionCollaboration.test.ts b/src/server/api/sessionCollaboration.test.ts index 369d7731..e698aced 100644 --- a/src/server/api/sessionCollaboration.test.ts +++ b/src/server/api/sessionCollaboration.test.ts @@ -4,6 +4,23 @@ import type { SessionCollaborationService } from '../services/sessionCollaborati const request = (body: unknown) => new Request('http://127.0.0.1/api/session-collaboration/send', { method: 'POST', body: JSON.stringify(body) }) +test('UI and tool search pass request cancellation to the query service', async () => { + const controller = new AbortController() + const signals: AbortSignal[] = [] + const service = { + candidates: async (_query: string, signal: AbortSignal) => { signals.push(signal); return { sessions: [] } }, + list: async ({ signal }: { signal: AbortSignal }) => { signals.push(signal); return { sessions: [] } }, + } as unknown as SessionCollaborationService + const url = new URL('http://127.0.0.1/api/session-collaboration?query=repair') + const uiRequest = new Request(url, { signal: controller.signal }) + expect((await handleSessionCollaborationUiApi(uiRequest, url, service)).status).toBe(200) + const toolRequest = new Request(url, { method: 'POST', body: JSON.stringify({ query: 'repair' }), signal: controller.signal }) + expect((await handleSessionCollaborationApi(toolRequest, 'list', 'caller', service)).status).toBe(200) + controller.abort() + expect(signals).toHaveLength(2) + expect(signals.every(signal => signal.aborted)).toBe(true) +}) + test('tool API derives message sender from authenticated caller, not request body', async () => { let args: unknown[] = [] const service = { send: async (...input: unknown[]) => { args = input; return { id: 'm' } } } as unknown as SessionCollaborationService diff --git a/src/server/api/sessionCollaboration.ts b/src/server/api/sessionCollaboration.ts index c09f635b..e68452ca 100644 --- a/src/server/api/sessionCollaboration.ts +++ b/src/server/api/sessionCollaboration.ts @@ -25,7 +25,7 @@ export async function handleSessionCollaborationApi(req: Request, action: string body = value } catch { throw ApiError.badRequest('A JSON object is required') } switch (action) { - case 'list': return Response.json(await service.list({ query: stringField(body, 'query'), limit: numberField(body, 'limit'), offset: numberField(body, 'offset') })) + case 'list': return Response.json(await service.list({ query: stringField(body, 'query'), limit: numberField(body, 'limit'), offset: numberField(body, 'offset'), signal: req.signal })) case 'read': return Response.json(await service.read(stringField(body, 'sessionId', true)!, { cursor: stringField(body, 'cursor'), limit: numberField(body, 'limit'), signal: req.signal, includeOutputs: body.includeOutputs === true, maxOutputCharsPerItem: numberField(body, 'maxOutputCharsPerItem'), @@ -53,7 +53,7 @@ export async function handleSessionCollaborationApi(req: Request, action: string export async function handleSessionCollaborationUiApi(req: Request, url: URL, service: SessionCollaborationService): Promise { try { const parts = url.pathname.split('/').filter(Boolean).slice(2) - if (!parts.length && req.method === 'GET') return Response.json(await service.candidates(url.searchParams.get('query') || undefined)) + if (!parts.length && req.method === 'GET') return Response.json(await service.candidates(url.searchParams.get('query') || undefined, req.signal)) const sessionId = parts[0] if (!sessionId) throw ApiError.notFound('Session id required') if (parts.length === 2 && parts[1] === 'status' && req.method === 'GET') return Response.json(await service.groupStatus(sessionId)) diff --git a/src/server/services/localIndex/coordinator.ts b/src/server/services/localIndex/coordinator.ts index b8e91c1b..6cb8200a 100644 --- a/src/server/services/localIndex/coordinator.ts +++ b/src/server/services/localIndex/coordinator.ts @@ -1437,6 +1437,18 @@ export function createLocalIndexCoordinator( } }, + getSessionSuggestionMetadata(sessionIds) { + if (!indexReadAllowed() || !index?.getSessionSuggestionMetadata) return null + try { return index.getSessionSuggestionMetadata(sessionIds) } + catch (error) { markDegraded(error, 'LOCAL_INDEX_READ_FAILED'); return null } + }, + + searchSessionMetadata(query, options): SessionIndexPage | null { + if (!indexReadAllowed() || !index?.searchSessionMetadata) return null + try { return index.searchSessionMetadata(query, options) } + catch (error) { markDegraded(error, 'LOCAL_INDEX_READ_FAILED'); return null } + }, + findSearchCandidates( filters: SessionSearchCandidateFilters, ): IndexedSessionSearchCandidate[] | null { diff --git a/src/server/services/localIndex/searchContentCoordinator.test.ts b/src/server/services/localIndex/searchContentCoordinator.test.ts index 156bcc4b..2bebbee2 100644 --- a/src/server/services/localIndex/searchContentCoordinator.test.ts +++ b/src/server/services/localIndex/searchContentCoordinator.test.ts @@ -175,16 +175,21 @@ describe('search content coordinator', () => { await coordinator.start() expect(coordinator.search('sqlite')).toBeNull() + expect(coordinator.suggestSessions('sqlite')).toBeNull() await waitForStatus(coordinator.getStatus, 'ready') expect(coordinator.search('sqlite')?.sessions[0]).toMatchObject({ ownerSessionId: 'owner-session', matchCount: 1, }) + expect(coordinator.suggestSessions('sqlite')?.sessions[0]?.ownerSessionId).toBe('owner-session') + expect(coordinator.suggestSessions('sqlite', { signal: AbortSignal.abort() })).toBeNull() + await mkdir(join(nested, '..'), { recursive: true }) await writeFile(nested, userLine('nested workflow needle', 'nested-message')) watcherOptions?.onDirty?.() expect(coordinator.search('needle')).toBeNull() + expect(coordinator.suggestSessions('needle')).toBeNull() await watcherOptions?.onBatch({ paths: [nested], fullSweep: false } satisfies ReconciliationBatch) await waitForStatus(coordinator.getStatus, 'ready') diff --git a/src/server/services/localIndex/searchContentCoordinator.ts b/src/server/services/localIndex/searchContentCoordinator.ts index 2caa5a45..4cfc7a80 100644 --- a/src/server/services/localIndex/searchContentCoordinator.ts +++ b/src/server/services/localIndex/searchContentCoordinator.ts @@ -10,6 +10,7 @@ import { import { createSearchContentIndex, type SearchContentIndex, + type SearchContentSuggestions, type SearchContentQueryOptions, type SearchContentQueryResult, } from './searchContentIndex.js' @@ -47,6 +48,7 @@ export type SearchContentCoordinatorStatus = { } export interface SearchContentCoordinator { + suggestSessions(query: string, options?: { limit?: number; signal?: AbortSignal }): SearchContentSuggestions | null start(): Promise stop(): Promise search( @@ -765,6 +767,17 @@ export function createSearchContentCoordinator( resetStatus: true, }) }, + suggestSessions(query, options = {}) { + if (!started || !hasCompleteSweep || status.state !== 'ready' || !index || options.signal?.aborted) return null + try { + const result = index.querySessionSuggestions(query, options.limit) + return options.signal?.aborted ? null : result + } catch { + // Suggestions are a disposable projection; never repair or scan + // transcript files on the keystroke path when SQLite is unavailable. + return null + } + }, search(query, options = {}) { if ( !started || diff --git a/src/server/services/localIndex/searchContentIndex.test.ts b/src/server/services/localIndex/searchContentIndex.test.ts index 53932648..25ee691d 100644 --- a/src/server/services/localIndex/searchContentIndex.test.ts +++ b/src/server/services/localIndex/searchContentIndex.test.ts @@ -244,3 +244,75 @@ describe('search content index', () => { } }) }) + +it('bounds Chinese broad-match suggestions to distinct owners without materializing transcript bodies', async () => { + const { database, index } = await setup() + try { + const body = '修复中文协作问题 '.repeat(160) + for (let owner = 0; owner < 120; owner++) { + index.replaceSource(source({ path: `/fixture/${owner}.jsonl`, ownerSessionId: `owner-${owner}`, + ownerTranscriptPath: `/fixture/${owner}.jsonl`, modifiedAtMs: owner }), + Array.from({ length: 100 }, (_, line) => ({ jsonlLine: line + 1, byteStart: line * 100, + byteLength: 100, segmentIndex: 0, role: 'user' as const, messageId: null, + timestamp: null, body, normalizedBody: normalizeSearchContent(body) }))) + } + index.replaceSource(source({ path: '/fixture/subagent.jsonl', ownerSessionId: 'owner-119', + ownerTranscriptPath: '/fixture/119.jsonl', modifiedAtMs: 119 }), [{ jsonlLine: 1, + byteStart: 0, byteLength: 100, segmentIndex: 0, role: 'assistant', messageId: null, + timestamp: null, body, normalizedBody: normalizeSearchContent(body) }]) + expect(index.querySessionSuggestions('修复', 20)).toBeNull() + index.setReadiness({ state: 'ready', discovered: 121, indexed: 121 }) + const start = performance.now() + const result = index.querySessionSuggestions('修复', 20)! + const elapsed = performance.now() - start + console.log(`[suggestion benchmark] 12001 matching Chinese documents: ${elapsed.toFixed(1)}ms, ${JSON.stringify(result).length} response chars`) + expect(result.sessions).toHaveLength(20) + expect(result.truncated).toBe(true) + expect(new Set(result.sessions.map(row => row.ownerSessionId)).size).toBe(20) + expect(result.sessions[0]?.ownerSessionId).toBe('owner-119') + expect(JSON.stringify(result)).not.toContain('修复') + expect(elapsed).toBeLessThan(1000) + expect(index.querySessionSuggestions('中文协作', 5)?.sessions).toHaveLength(5) + expect(index.querySessionSuggestions('不存在', 5)?.sessions).toHaveLength(0) + } finally { database.close() } +}) + +it('scans global FTS once for common and rare long queries across a thousand sources', async () => { + const { database, index } = await setup() + try { + for (let owner = 0; owner < 1000; owner++) { + const body = '修复一下这些中文协作问题 '.repeat(80) + (owner === 999 ? '稀有字符串专用' : '') + index.replaceSource(source({ path: `/fixture/${owner}.jsonl`, ownerSessionId: `owner-${owner}`, + ownerTranscriptPath: `/fixture/${owner}.jsonl`, modifiedAtMs: owner }), + Array.from({ length: 10 }, (_, line) => ({ jsonlLine: line + 1, byteStart: line * 100, + byteLength: 100, segmentIndex: 0, role: 'user' as const, messageId: null, + timestamp: null, body, normalizedBody: normalizeSearchContent(body) }))) + } + index.setReadiness({ state: 'ready', discovered: 1000, indexed: 1000 }) + const plans: string[][] = [] + const inspected = createSearchContentIndex({ ...database, + read: operation => database.read(reader => operation({ ...reader, + all: (sql, ...bindings) => { + if (sql.includes('search_documents_fts MATCH')) { + plans.push(reader.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, ...bindings).map(row => row.detail)) + } + return reader.all(sql, ...bindings) + }, + })), + }, { scope: index.getReadiness()!.scope }) + for (const [query, count] of [['修复一下', 60], ['稀有字符串专用', 1]] as const) { + const start = performance.now() + const result = inspected.querySessionSuggestions(query, 60)! + console.log(`[FTS suggestion benchmark] 1000 sources / 10000 documents, ${query}: ${(performance.now() - start).toFixed(1)}ms`) + expect(result.sessions).toHaveLength(count) + expect(result.sessions[0]?.ownerSessionId).toBe('owner-999') + expect(result.truncated).toBe(count === 60) + expect(new Set(result.sessions.map(item => item.ownerSessionId)).size).toBe(count) + } + expect(plans).toHaveLength(2) + for (const plan of plans) { + expect(plan.some(step => step.includes('CORRELATED'))).toBe(false) + expect(plan.filter(step => step.includes('search_documents_fts'))).toHaveLength(1) + } + } finally { database.close() } +}) diff --git a/src/server/services/localIndex/searchContentIndex.ts b/src/server/services/localIndex/searchContentIndex.ts index 25ba0e8d..3e752687 100644 --- a/src/server/services/localIndex/searchContentIndex.ts +++ b/src/server/services/localIndex/searchContentIndex.ts @@ -106,7 +106,13 @@ export type SearchContentQueryOptions = { caseSensitive?: boolean } +export type SearchContentSuggestions = { + sessions: Array<{ ownerSessionId: string; ownerTranscriptPath: string; projectPath: string; modifiedAtMs: number }> + truncated: boolean +} + export interface SearchContentIndex { + querySessionSuggestions(query: string, limit?: number): SearchContentSuggestions | null getSource(path: string): SearchContentSource | null listSources(): SearchContentSource[] countSources(): number @@ -396,6 +402,45 @@ export function createSearchContentIndex( readiness.updatedAtMs ?? now()) }) }, + querySessionSuggestions(query, requestedLimit) { + const normalized = normalizeSearchContent(query.trim()) + if (!normalized) return { sessions: [], truncated: false } + const ready = database.read(reader => reader.get( + 'SELECT * FROM search_backfill_state WHERE scope = ?', options.scope, + )) + if (ready?.state !== 'ready') return null + const limit = clampInteger(requestedLimit, DEFAULT_SESSION_LIMIT, MAX_SESSION_LIMIT) + const useFts = Array.from(normalized).length >= 3 + // Scan global FTS matches once. Correlating MATCH with each source + // repeats that global scan for every transcript on common long queries. + // Short queries instead seek each source and stop at its first segment. + const selection = useFts + ? `FROM search_documents_fts + JOIN search_documents AS document ON document.id = search_documents_fts.rowid + JOIN search_sources AS source ON source.path = document.source_path + WHERE search_documents_fts MATCH ? AND source.state = 'ready' + AND instr(document.normalized_body, ?) > 0` + : `FROM search_sources AS source + WHERE source.state = 'ready' AND EXISTS ( + SELECT 1 FROM search_documents AS document + WHERE document.source_path = source.path AND instr(document.normalized_body, ?) > 0 + )` + const rows = database.read(reader => reader.all<{ + owner_session_id: string; owner_transcript_path: string; project_path: string; modified_at_ms: number + }>(` + SELECT source.owner_session_id, source.owner_transcript_path, + source.project_path, MAX(source.modified_at_ms) AS modified_at_ms + ${selection} + GROUP BY source.owner_transcript_path + ORDER BY modified_at_ms DESC, source.owner_transcript_path + LIMIT ? + `, ...(useFts ? [ftsPhrase(normalized)] : []), normalized, limit + 1)) + return { + sessions: rows.slice(0, limit).map(row => ({ ownerSessionId: row.owner_session_id, + ownerTranscriptPath: row.owner_transcript_path, projectPath: row.project_path, modifiedAtMs: row.modified_at_ms })), + truncated: rows.length > limit, + } + }, query(query, queryOptions = {}) { const literalQuery = query.trim() const normalizedQuery = normalizeSearchContent(literalQuery) diff --git a/src/server/services/localIndex/sessionIndex.ts b/src/server/services/localIndex/sessionIndex.ts index cb9fc1be..543d3bfc 100644 --- a/src/server/services/localIndex/sessionIndex.ts +++ b/src/server/services/localIndex/sessionIndex.ts @@ -57,6 +57,8 @@ export type SessionEntryLocatorPage = { } export interface SessionIndexReader { + getSessionSuggestionMetadata?(sessionIds: string[]): IndexedSessionRow[] | null + searchSessionMetadata?(query: string, options?: { limit?: number; offset?: number }): SessionIndexPage | null listSessions(options?: { project?: string limit?: number @@ -300,6 +302,46 @@ export function createSessionIndex(database: LocalIndexDatabase): SessionIndex { }) }, + getSessionSuggestionMetadata(sessionIds): IndexedSessionRow[] { + const ids = [...new Set(sessionIds)].slice(0, 100) + if (!ids.length) return [] + return database.read(operation => operation.all(` + SELECT transcript_path, session_id, project_path, title, created_at, + modified_at, message_count, work_dir, permission_mode, + runtime_provider_id, runtime_provider_present, runtime_model_id, + effort_level, repository_json, worktree_session_json + FROM sessions WHERE session_id IN (${ids.map(() => '?').join(',')}) + ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC + LIMIT 100 + `, ...ids).map(sessionFromRow)) + }, + + searchSessionMetadata(query, options): SessionIndexPage { + const needle = query.trim().toLowerCase() + const limit = Math.min(100, Math.max(1, boundedInteger(options?.limit, 30))) + const offset = boundedInteger(options?.offset, 0) + return database.read(operation => { + const where = `instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 OR instr(lower(coalesce(work_dir, '')), ?) > 0 OR instr(lower(project_path), ?) > 0` + const total = operation.get<{ total: number }>(`SELECT COUNT(*) AS total FROM sessions WHERE ${where}`, needle, needle, needle, needle)?.total ?? 0 + const rows = operation.all(` + SELECT transcript_path, session_id, project_path, title, created_at, + modified_at, message_count, work_dir, permission_mode, + runtime_provider_id, runtime_provider_present, runtime_model_id, + effort_level, repository_json, worktree_session_json + FROM sessions WHERE ${where} + ORDER BY CASE + WHEN ? = '' THEN 0 + WHEN lower(title) = ? OR lower(session_id) = ? THEN 3 + WHEN instr(lower(title), ?) = 1 OR instr(lower(session_id), ?) = 1 THEN 2 + WHEN instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 THEN 1 + ELSE 0 END DESC, + modified_at_ms DESC, session_id ASC, transcript_path ASC + LIMIT ? OFFSET ? + `, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, limit, offset) + return { sessions: rows.map(sessionFromRow), total } + }) + }, + findSessionFiles(sessionId): SessionFileMatch[] { return database.read(operation => operation.all<{ transcript_path: string diff --git a/src/server/services/localIndex/sessionMetadataSearch.test.ts b/src/server/services/localIndex/sessionMetadataSearch.test.ts new file mode 100644 index 00000000..6ea8a49b --- /dev/null +++ b/src/server/services/localIndex/sessionMetadataSearch.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { openLocalIndexDatabase, type LocalIndexDatabase } from './database.js' +import { createSessionIndex, type SessionIndex } from './sessionIndex.js' + +let directory: string +let database: LocalIndexDatabase +let index: SessionIndex + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'metadata-search-')) + database = openLocalIndexDatabase({ scope: directory, path: join(directory, 'cc-haha/db/index-v1.sqlite') }) + index = createSessionIndex(database) +}) + +afterEach(async () => { + database.close() + await rm(directory, { recursive: true, force: true }) +}) + +function seed(rows: Array<{ id: string; title: string; modified?: number; workDir?: string }>) { + database.transaction(op => { + for (const row of rows) { + const path = `/fixture/${row.id}.jsonl` + op.run("INSERT INTO source_files(path,kind,size_bytes,mtime_ms,prefix_hash,parser_version,state,updated_at_ms) VALUES (?,'transcript',0,0,'fixture',1,'ready',0)", path) + op.run('INSERT INTO sessions(transcript_path,session_id,project_path,title,created_at,modified_at,modified_at_ms,message_count,work_dir) VALUES (?,?,?,?,?,?,?,?,?)', + path, row.id, 'fixture-project', row.title, '2026-01-01', + new Date(row.modified ?? 0).toISOString(), row.modified ?? 0, 1, row.workDir ?? '/fixture', + ) + } + }) +} + +test('Chinese exact title and id outrank thousands of recent fuzzy hits before LIMIT', () => { + seed([ + ...Array.from({ length: 10000 }, (_, i) => ({ id: `recent-${i}`, title: `最近讨论设计文档 ${i}`, modified: i + 100 })), + { id: 'old-title', title: '设计文档' }, + { id: '设计文档', title: '旧对话' }, + ]) + const started = performance.now() + const result = index.searchSessionMetadata!('设计文档', { limit: 30 })! + expect(result.total).toBe(10002) + expect(result.sessions).toHaveLength(30) + expect(result.sessions.slice(0, 2).map(row => row.id)).toEqual(['old-title', '设计文档']) + const second = index.searchSessionMetadata!('设计文档', { limit: 30, offset: 30 })! + expect(second.sessions.some(row => result.sessions.some(previous => previous.id === row.id))).toBe(false) + console.info(`[metadata-search benchmark] 10002 rows, two bounded queries: ${(performance.now() - started).toFixed(1)}ms`) +}) + +test('wildcards are literal, path matches work, and metadata batches stay bounded', () => { + seed([ + { id: 'literal', title: '100%_done' }, + { id: 'wildcard-lookalike', title: '100XXdone' }, + { id: 'path', title: 'other', workDir: '/项目/中文路径' }, + ...Array.from({ length: 150 }, (_, i) => ({ id: `batch-${i}`, title: 'batch' })), + ]) + expect(index.searchSessionMetadata!('%_', { limit: 30 })!.sessions.map(row => row.id)).toEqual(['literal']) + expect(index.searchSessionMetadata!('中文路径')!.sessions.map(row => row.id)).toEqual(['path']) + expect(index.searchSessionMetadata!('FIXTURE-PROJECT')!.total).toBe(153) + expect(index.getSessionSuggestionMetadata!(Array.from({ length: 150 }, (_, i) => `batch-${i}`))).toHaveLength(100) + expect(index.getSessionSuggestionMetadata!([])).toEqual([]) +}) + +test('empty query is recent metadata order even when an old title is empty', () => { + seed([ + { id: 'old-empty', title: '', modified: 0 }, + { id: 'recent', title: 'recent', modified: 100 }, + { id: 'middle', title: 'middle', modified: 50 }, + ]) + expect(index.searchSessionMetadata!('', { limit: 2 })!.sessions.map(row => row.id)).toEqual(['recent', 'middle']) +}) diff --git a/src/server/services/searchService.suggestions.test.ts b/src/server/services/searchService.suggestions.test.ts new file mode 100644 index 00000000..6582cbaf --- /dev/null +++ b/src/server/services/searchService.suggestions.test.ts @@ -0,0 +1,26 @@ +import { expect, mock, test } from 'bun:test' +import { SearchService } from './searchService.js' + +test('session suggestions use only projected IDs even when files are unavailable', async () => { + const forbidden = mock(() => { throw new Error('Transcript IO is forbidden for suggestions') }) + const service = new SearchService({ + searchIndexedContent: forbidden, readEntriesAtLines: forbidden, + getMetadataForPaths: forbidden, getCandidatesForFilters: forbidden, resolveRipgrepCommand: forbidden, + suggestIndexedSessions: () => ({ sessions: [{ ownerSessionId: 'id', ownerTranscriptPath: '/missing/id.jsonl', projectPath: '-repo', modifiedAtMs: 1 }], truncated: true }), + }) + expect(await service.searchSessionSuggestions('修复')).toEqual({ sessions: [{ sessionId: 'id', ownerTranscriptPath: '/missing/id.jsonl', projectPath: '-repo', modifiedAt: new Date(1).toISOString() }], truncated: true, indexUnavailable: false }) + expect(forbidden).not.toHaveBeenCalled() +}) + +test('unready indexes produce an explicit partial response without fallback and honor cancellation', async () => { + const forbidden = mock(() => { throw new Error('No fallback') }) + const service = new SearchService({ suggestIndexedSessions: () => null, searchIndexedContent: forbidden, resolveRipgrepCommand: forbidden }) + expect(await service.searchSessionSuggestions('修复')).toEqual({ sessions: [], truncated: true, indexUnavailable: true }) + expect(forbidden).not.toHaveBeenCalled() + const controller = new AbortController() + controller.abort() + await expect(service.searchSessionSuggestions('修复', { signal: controller.signal })).rejects.toThrow() + const during = new AbortController() + const cancelling = new SearchService({ suggestIndexedSessions: () => { during.abort(); return null } }) + await expect(cancelling.searchSessionSuggestions('修复', { signal: during.signal })).rejects.toThrow() +}) diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index 567c8d63..632f60e7 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -27,6 +27,7 @@ import { searchContentCoordinator, } from './localIndex/searchContentCoordinator.js' import type { + SearchContentSuggestions, SearchContentMatch, SearchContentQueryOptions, SearchContentQueryResult, @@ -203,6 +204,7 @@ type SearchEntryLineRead = { } type SearchServiceOptions = { + suggestIndexedSessions?: (query: string, options: { limit?: number; signal?: AbortSignal }) => SearchContentSuggestions | null readEntriesAtLines?: ( filePath: string, lineNumbers: Set, @@ -231,6 +233,7 @@ function isAbortError(error: unknown): boolean { } export class SearchService { + private readonly suggestIndexedSessions: NonNullable private readonly readEntriesAtLines: NonNullable private readonly getMetadataForPaths: NonNullable private readonly getCandidatesForFilters: NonNullable @@ -239,6 +242,7 @@ export class SearchService { private readonly commandAvailability = new Map>() constructor(options: SearchServiceOptions = {}) { + this.suggestIndexedSessions = options.suggestIndexedSessions ?? ((query, options) => searchContentCoordinator.suggestSessions(query, options)) this.readEntriesAtLines = options.readEntriesAtLines ?? ((filePath, lineNumbers) => sessionService.readSessionEntriesAtLines( filePath, @@ -302,6 +306,21 @@ export class SearchService { // 会话历史搜索 // --------------------------------------------------------------------------- + /** Menu suggestions deliberately trust the disposable index, never opening canonical transcripts. */ + async searchSessionSuggestions(query: string, options: { limit?: number; signal?: AbortSignal } = {}) { + throwIfAborted(options.signal) + if (!query.trim()) return { sessions: [], truncated: false, indexUnavailable: false } + const projected = this.suggestIndexedSessions(query, options) + throwIfAborted(options.signal) + return { + sessions: projected?.sessions.map(item => ({ sessionId: item.ownerSessionId, + ownerTranscriptPath: item.ownerTranscriptPath, projectPath: item.projectPath, + modifiedAt: new Date(item.modifiedAtMs).toISOString() })) ?? [], + truncated: projected?.truncated ?? true, + indexUnavailable: projected === null, + } + } + /** * Full-text search across all session transcripts. * diff --git a/src/server/services/sessionCollaborationHost.test.ts b/src/server/services/sessionCollaborationHost.test.ts index d4c66fb9..e8c3b87b 100644 --- a/src/server/services/sessionCollaborationHost.test.ts +++ b/src/server/services/sessionCollaborationHost.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionCollaborationService, type CollaborationMessage } from './sessionCollaborationService.js' -import { handleSessionCollaborationEvent, configureSessionCollaborationHost, getSessionCollaborationService, findSessionMetadataMatches } from './sessionCollaborationHost.js' +import { handleSessionCollaborationEvent, configureSessionCollaborationHost, getSessionCollaborationService } from './sessionCollaborationHost.js' import { sessionService } from './sessionService.js' import { admitSessionUserTurn, emitSessionTurnEvent } from './sessionTurnEvents.js' import { SearchService } from './searchService.js' @@ -69,24 +69,6 @@ test('host rejects unavailable source workspace explicitly instead of silently c } }) -test('metadata search finds old titles and ids beyond the first hundred sessions without conflating equal titles', async () => { - const sessions = Array.from({ length: 235 }, (_, index) => ({ - id: `session-${index}`, title: index === 230 ? 'Unique archived design' : index === 1 || index === 201 ? 'Same title' : 'Ordinary', - workDir: index === 234 ? '/fixture/old-project' : '/fixture/project', - })) - const requests: number[] = [] - const list = async ({ limit, offset }: { limit: number; offset: number }) => { - requests.push(offset) - return { sessions: sessions.slice(offset, offset + limit), total: sessions.length } - } - expect((await findSessionMetadataMatches('unique archived', list)).map(item => item.id)).toEqual(['session-230']) - expect(requests).toEqual([0, 100, 200]) - expect((await findSessionMetadataMatches('session-229', list)).map(item => item.id)).toEqual(['session-229']) - expect((await findSessionMetadataMatches('same title', list)).map(item => item.id)).toEqual(['session-1', 'session-201']) - expect((await findSessionMetadataMatches('/old-project', list)).map(item => item.id)).toEqual(['session-234']) -}) - - test('manual admission waits for already emitted Stop events before reserving its slot', async () => { const previous = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = directory @@ -117,25 +99,52 @@ test('manual admission waits for already emitted Stop events before reserving it }) -test('old exact title and id matches rank before the server candidate limit', async () => { +test('a full metadata page skips transcript search and returns immediately from the bounded index', async () => { const previous = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = directory const dispose = configureSessionCollaborationHost('127.0.0.1', 1234) - const sessions = [ - ...Array.from({ length: 45 }, (_, i) => ({ id: `recent-${i}`, title: `Recent needle discussion ${i}`, workDir: '/fixture' })), - { id: 'old-title', title: 'needle', workDir: '/fixture' }, - { id: 'needle', title: 'Old conversation', workDir: '/fixture' }, - ] - spyOn(sessionService, 'listSessions').mockImplementation(async ({ offset = 0, limit = 30 } = {}) => ({ sessions: sessions.slice(offset, offset + limit), total: sessions.length }) as any) - spyOn(SearchService.prototype, 'searchSessions').mockResolvedValue({ results: [], truncated: false } as any) + const sessions = Array.from({ length: 30 }, (_, i) => ({ id: `match-${i}`, title: '修复', workDir: '/fixture', projectPath: 'project', modifiedAt: 'now' })) + const metadata = spyOn(sessionService, 'searchSessionMetadata').mockResolvedValue({ sessions, total: 20_000 }) + const list = spyOn(sessionService, 'listSessions').mockRejectedValue(new Error('Must not hydrate the sidebar')) + const fullText = spyOn(SearchService.prototype, 'searchSessions').mockRejectedValue(new Error('Must not read transcripts')) + const suggestions = spyOn(SearchService.prototype, 'searchSessionSuggestions').mockRejectedValue(new Error('Already have a full metadata page')) try { const service = await getSessionCollaborationService() - const result = await service.candidates('needle') - expect(result.sessions).toHaveLength(30) - expect(result.sessions.slice(0, 2).map(session => session.sessionId)).toEqual(['old-title', 'needle']) + const result = await service.candidates('修复') + expect(result.sessions.map(item => item.sessionId)).toEqual(sessions.map(item => item.id)) + expect(metadata).toHaveBeenCalledWith('修复', { limit: 30, offset: 0, signal: undefined }) + expect(await service.list({ query: '修复' })).toMatchObject({ total: 20_000, totalIsLowerBound: true, truncated: true }) + expect(list).not.toHaveBeenCalled() + expect(fullText).not.toHaveBeenCalled() + expect(suggestions).not.toHaveBeenCalled() } finally { - dispose() - mock.restore() + dispose(); mock.restore() + if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previous + } +}) + +test('content suggestions fill metadata pages without duplicate metadata hits or canonical history reads', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = directory + const dispose = configureSessionCollaborationHost('127.0.0.1', 1234) + const title = { id: 'title', title: 'Project match', workDir: '/fixture', projectPath: '修复-project', modifiedAt: 'now' } + const body = { id: 'body', title: 'Older discussion', workDir: '/fixture', projectPath: 'project', modifiedAt: 'before' } + spyOn(sessionService, 'searchSessionMetadata').mockImplementation(async (_query, options) => ({ sessions: options?.offset ? [] : [title], total: 1 })) + spyOn(sessionService, 'getSessionSuggestionMetadata').mockReturnValue([title, body]) + const fullText = spyOn(SearchService.prototype, 'searchSessions').mockRejectedValue(new Error('Must not scan files')) + spyOn(SearchService.prototype, 'searchSessionSuggestions').mockResolvedValue({ + sessions: [title, body, body].map(item => ({ sessionId: item.id, projectPath: item.projectPath, modifiedAt: item.modifiedAt, ownerTranscriptPath: `/fixture/${item.id}.jsonl` })), + truncated: false, indexUnavailable: false, + }) + try { + const service = await getSessionCollaborationService() + expect((await service.candidates('修复')).sessions.map(item => item.sessionId)).toEqual(['title', 'body']) + expect((await service.list({ query: '修复', offset: 1, limit: 1 }) as any).sessions.map((item: any) => item.id)).toEqual(['body']) + expect((await service.list({ query: '修复', offset: 2, limit: 1 }) as any).sessions).toEqual([]) + expect(fullText).not.toHaveBeenCalled() + } finally { + dispose(); mock.restore() if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR else process.env.CLAUDE_CONFIG_DIR = previous } diff --git a/src/server/services/sessionCollaborationHost.ts b/src/server/services/sessionCollaborationHost.ts index d1cdbeaf..a96b5614 100644 --- a/src/server/services/sessionCollaborationHost.ts +++ b/src/server/services/sessionCollaborationHost.ts @@ -11,28 +11,6 @@ import { ApiError } from '../middleware/errorHandler.js' const searchService = new SearchService() -/** Scan only the existing metadata projection; transcript content has its own bounded index search. */ -export async function findSessionMetadataMatches( - query: string, - list: (options: { limit: number; offset: number }) => Promise<{ sessions: Array<{ id: string; title?: string; workDir?: string | null; projectPath?: string }>; total?: number }>, -): Promise>> { - const needle = query.toLocaleLowerCase() - const matches = new Map>() - const seen = new Set() - for (let offset = 0; ; offset += 100) { - const page = await list({ limit: 100, offset }) - let newSessions = 0 - for (const session of page.sessions) { - if (seen.has(session.id)) continue - seen.add(session.id) - newSessions++ - if (`${session.title ?? ''} ${session.id} ${session.workDir ?? session.projectPath ?? ''}`.toLocaleLowerCase().includes(needle)) matches.set(session.id, session) - } - if (page.sessions.length < 100 || newSessions === 0 || (page.total !== undefined && offset + page.sessions.length >= page.total)) break - } - return [...matches.values()] -} - let endpoint = { serverHost: '127.0.0.1', serverPort: 0 } let current: { path: string; service: SessionCollaborationService; ready: Promise } | undefined let unsubscribe: (() => void) | undefined @@ -62,27 +40,36 @@ export async function getSessionCollaborationService(): Promise sessionService.listSessions(options)), - ]) - const selected = new Map>() - for (const session of metadata) selected.set(String(session.id), session) - for (const match of matches.results) if (!selected.has(match.sessionId)) selected.set(match.sessionId, { ...match, id: match.sessionId }) - const needle = query.trim().toLocaleLowerCase() - const rank = (session: Record): number => { - const names = [session.id ?? session.sessionId, session.title].filter(value => typeof value === 'string').map(value => String(value).toLocaleLowerCase()) - if (names.some(value => value === needle)) return 3 - if (names.some(value => value.startsWith(needle))) return 2 - if (names.some(value => value.includes(needle))) return 1 - return 0 + async list({ query, limit, offset, signal }) { + const needle = query?.trim() ?? '' + const metadata = await sessionService.searchSessionMetadata(needle, { limit, offset, signal }) + signal?.throwIfAborted() + // A full metadata page already answers the picker. Do not wait for + // full-text matches (or canonical transcript validation) to display it. + if (!needle) return metadata + if (metadata.sessions.length === limit) return { ...metadata, truncated: true, totalIsLowerBound: true } + const content = await searchService.searchSessionSuggestions(needle, { limit: 100, signal }) + signal?.throwIfAborted() + const details = new Map(sessionService.getSessionSuggestionMetadata(content.sessions.map(item => item.sessionId)).map(item => [item.id, item])) + const normalized = needle.toLowerCase() + const seen = new Set() + const bodyOnly = content.sessions.flatMap(item => { + const detail = details.get(item.sessionId) + // The metadata page is authoritative for title/project matches; + // filter these from content pages too so pagination cannot repeat them. + if (seen.has(item.sessionId)) return [] + seen.add(item.sessionId) + if (detail && [detail.id, detail.title, detail.workDir ?? '', detail.projectPath].some(value => value.toLowerCase().includes(normalized))) return [] + return [detail ?? { id: item.sessionId, title: item.sessionId, projectPath: item.projectPath, modifiedAt: item.modifiedAt }] + }) + const bodyOffset = Math.max(0, offset - metadata.total) + return { + sessions: [...metadata.sessions, ...bodyOnly.slice(bodyOffset, bodyOffset + limit - metadata.sessions.length)], + total: metadata.total + bodyOnly.length, + truncated: content.truncated, + totalIsLowerBound: content.truncated || content.indexUnavailable, + indexUnavailable: content.indexUnavailable, } - const ranked = [...selected.values()].sort((a, b) => rank(b) - rank(a)) - return { sessions: ranked.slice(offset, offset + limit), total: selected.size, truncated: matches.truncated } }, read: (sessionId, options) => sessionService.getSessionHistoryPage(sessionId, options), exists: async sessionId => Boolean(await sessionService.getSessionSummary(sessionId)), diff --git a/src/server/services/sessionCollaborationService.test.ts b/src/server/services/sessionCollaborationService.test.ts index 3904ac8a..ac2998a3 100644 --- a/src/server/services/sessionCollaborationService.test.ts +++ b/src/server/services/sessionCollaborationService.test.ts @@ -29,6 +29,28 @@ beforeEach(async () => { afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) describe('session collaboration', () => { + test('suggestions project current member status without materializing message history', async () => { + await service.onSessionState('root', 'blocked') + service.status = async () => { throw new Error('Suggestions must not clone the full collaboration history') } + expect((await service.candidates('Main')).sessions).toEqual([ + { sessionId: 'root', title: 'Main', cwd: '/fixture', status: 'blocked', updatedAt: 'now' }, + ]) + }) + + test('suggestions propagate cancellation and reject cancelled results', async () => { + const controller = new AbortController() + let calls = 0 + deps.sessions.list = async options => { + calls++ + expect(options.signal).toBe(controller.signal) + controller.abort(new Error('Query replaced')) + return { sessions: [] } + } + await expect(service.candidates('old query', controller.signal)).rejects.toThrow('Query replaced') + await expect(service.candidates('old query', controller.signal)).rejects.toThrow('Query replaced') + expect(calls).toBe(1) + }) + test('shares three worker slots across nested delegation and releases them on completion', async () => { const a = await service.create('root', { prompt: 'a' }) expect(a).toMatchObject({ state: 'running', delivery: 'accepted' }) diff --git a/src/server/services/sessionCollaborationService.ts b/src/server/services/sessionCollaborationService.ts index c4a26af0..64505842 100644 --- a/src/server/services/sessionCollaborationService.ts +++ b/src/server/services/sessionCollaborationService.ts @@ -36,7 +36,7 @@ export type CollaborationCreateResult = { sessionId: string; workDir?: string; m export type SessionCollaborationDependencies = { statePath: string sessions: { - list(options: { query?: string; limit: number; offset: number }): Promise + list(options: { query?: string; limit: number; offset: number; signal?: AbortSignal }): Promise read(sessionId: string, options: { cursor?: string; limit: number; signal?: AbortSignal }): Promise exists(sessionId: string): Promise /** Must use the caller's effective runtime and an isolated Git worktree. */ @@ -176,17 +176,24 @@ export class SessionCollaborationService { return message } - async list(options: { query?: string; limit?: number; offset?: number } = {}): Promise { - return this.deps.sessions.list({ ...options, limit: Math.min(100, Math.max(1, options.limit ?? 30)), offset: Math.max(0, options.offset ?? 0) }) + async list(options: { query?: string; limit?: number; offset?: number; signal?: AbortSignal } = {}): Promise { + options.signal?.throwIfAborted() + const result = await this.deps.sessions.list({ ...options, limit: Math.min(100, Math.max(1, options.limit ?? 30)), offset: Math.max(0, options.offset ?? 0) }) + options.signal?.throwIfAborted() + return result } - async candidates(query?: string): Promise<{ sessions: Array<{ sessionId: string; title: string; cwd: string; status: string; updatedAt: string }> }> { - const result = await this.list({ query }) as { sessions?: Array> } - const snapshot = await this.status() + async candidates(query?: string, signal?: AbortSignal): Promise<{ sessions: Array<{ sessionId: string; title: string; cwd: string; status: string; updatedAt: string }> }> { + const result = await this.list({ query, signal }) as { sessions?: Array> } + await this.ready + await this.tail + signal?.throwIfAborted() + // Suggestions need only a status lookup for the returned page, not a clone + // of every collaboration message and member in the application. return { sessions: (result.sessions ?? []).map(session => ({ sessionId: String(session.sessionId ?? session.id), title: String(session.title ?? ''), cwd: String(session.cwd ?? session.workDir ?? session.projectPath ?? ''), - status: snapshot.members.find(member => member.sessionId === (session.sessionId ?? session.id))?.state ?? 'idle', + status: this.store.members[String(session.sessionId ?? session.id)]?.state ?? 'idle', updatedAt: String(session.updatedAt ?? session.modifiedAt ?? ''), })) } } diff --git a/src/server/services/sessionMetadataSearch.test.ts b/src/server/services/sessionMetadataSearch.test.ts new file mode 100644 index 00000000..b62d39a1 --- /dev/null +++ b/src/server/services/sessionMetadataSearch.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, expect, spyOn, mock, test } from 'bun:test' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { SessionService } from './sessionService.js' +import type { LocalIndexGateway } from './localIndex/sessionIndex.js' + +let directory: string +let previousHome: string | undefined +let previousConfig: string | undefined + +beforeEach(async () => { + directory = await mkdtemp('/tmp/session-metadata-') + previousHome = process.env.HOME + previousConfig = process.env.CLAUDE_CONFIG_DIR + process.env.HOME = directory + process.env.CLAUDE_CONFIG_DIR = directory +}) + +afterEach(async () => { + mock.restore() + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfig + await rm(directory, { recursive: true, force: true }) +}) + +test('building index serves bounded metadata without canonical hydration', async () => { + const rows = [{ id: 'old', title: '设计', workDir: '/fixture', projectPath: 'fixture', modifiedAt: '2026-01-01' }] + const gateway = { + getMode: () => 'on', + getPublicStatus: () => ({ state: 'building' }), + searchSessionMetadata: () => ({ sessions: rows, total: 1 }), + getSessionSuggestionMetadata: () => rows, + } as unknown as LocalIndexGateway + const service = new SessionService(gateway) + const hydrate = spyOn(service, 'listSessions').mockImplementation(async () => { + throw new Error('no hydration') + }) + expect((await service.searchSessionMetadata('设计')).sessions).toEqual(rows) + expect(service.getSessionSuggestionMetadata(['old'])).toEqual(rows) + expect(hydrate).not.toHaveBeenCalled() + const abort = new AbortController() + abort.abort() + await expect(service.searchSessionMetadata('设计', { signal: abort.signal })).rejects.toThrow() +}) + +test('unavailable index scans cached summaries once and ranks old exact matches before limiting', async () => { + const project = join(directory, 'projects', 'fixture') + await mkdir(project, { recursive: true }) + for (let i = 0; i < 45; i++) { + await writeFile(join(project, `id-${i}.jsonl`), JSON.stringify({ + type: 'custom-title', + customTitle: i === 44 ? '设计' : '最近设计讨论', + sessionId: `id-${i}`, + }) + '\n') + } + const service = new SessionService({ getMode: () => 'off' } as unknown as LocalIndexGateway) + const hydrate = spyOn(service, 'listSessions').mockImplementation(async () => { + throw new Error('no hydration') + }) + const result = await service.searchSessionMetadata('设计', { limit: 5 }) + expect(result.sessions[0]?.id).toBe('id-44') + expect(result.total).toBe(45) + expect(result.sessions).toHaveLength(5) + expect(hydrate).not.toHaveBeenCalled() + expect(service.getSessionSuggestionMetadata(['id-44'])).toEqual([]) +}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 7740ad2b..873b8bcf 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -3351,6 +3351,54 @@ export class SessionService { return rows } + getSessionSuggestionMetadata(sessionIds: string[]): Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }> { + this.syncSharedMutationEpoch() + if (this.getUsableIndexMode() !== 'on') return [] + try { + const rows = this.localIndexGateway.getSessionSuggestionMetadata?.(sessionIds.slice(0, 100)) ?? [] + if (!this.indexStatusRemainsUsable()) return [] + return rows.map(({ id, title, workDir, projectPath, modifiedAt }) => ({ id, title, workDir, projectPath, modifiedAt })) + } catch { this.markIndexReadFailure(); return [] } + } + + /** Metadata-only reference lookup: no per-result transcript or workspace hydration. */ + async searchSessionMetadata(query: string, options: { limit?: number; offset?: number; signal?: AbortSignal } = {}): Promise<{ sessions: Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }>; total: number }> { + options.signal?.throwIfAborted() + this.syncSharedMutationEpoch() + const limit = Math.min(100, Math.max(1, options.limit ?? 30)) + const offset = Math.max(0, options.offset ?? 0) + const epoch = getSharedSessionMutationState(this.localIndexGateway).epoch + if (this.getUsableIndexMode() === 'on') { + try { + const result = this.localIndexGateway.searchSessionMetadata?.(query, { limit, offset }) + if (result && epoch === getSharedSessionMutationState(this.localIndexGateway).epoch && this.indexStatusRemainsUsable()) { + return { sessions: result.sessions.map(({ id, title, workDir, projectPath, modifiedAt }) => ({ id, title, workDir, projectPath, modifiedAt })), total: result.total } + } + } catch { this.markIndexReadFailure() } + } + // Scan the metadata projection once, rank before limiting, and reuse its + // summary cache. Do not hydrate every workspace or repeatedly page lists. + const scope = this.getConfigDir() + this.prepareSessionListCaches(scope) + const rows: Array<{ id: string; title: string; workDir: string | null; projectPath: string; modifiedAt: string }> = [] + for (const file of await this.discoverSessionFiles(undefined, scope)) { + options.signal?.throwIfAborted() + try { + const summary = await this.getCachedSessionListSummary(file.filePath, file.projectDir, await fs.stat(file.filePath), scope) + rows.push({ id: file.sessionId, title: summary.title, workDir: summary.workDir, projectPath: file.projectDir, modifiedAt: summary.modifiedAt }) + } catch { /* Match sidebar behavior for unreadable transcripts. */ } + } + const needle = query.trim().toLowerCase() + const rank = (row: typeof rows[number]) => { + if (!needle) return 0 + const names = [row.title.toLowerCase(), row.id.toLowerCase()] + return names.includes(needle) ? 3 : names.some(value => value.startsWith(needle)) ? 2 : names.some(value => value.includes(needle)) ? 1 : 0 + } + const matches = rows.filter(row => [row.title, row.id, row.workDir ?? '', row.projectPath].some(value => value.toLowerCase().includes(needle))) + matches.sort((a, b) => rank(b) - rank(a) || Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt) || a.id.localeCompare(b.id) || a.projectPath.localeCompare(b.projectPath)) + return { sessions: matches.slice(offset, offset + limit), total: matches.length } + } + /** List all sessions, optionally filtered by physical project path. */ async listSessions(options?: { project?: string