fix(desktop): query session suggestions directly from SQLite

This commit is contained in:
程序员阿江(Relakkes)
2026-09-21 12:04:19 +08:00
parent 7c7ce7d6b9
commit 3dbf7493a2
26 changed files with 636 additions and 110 deletions
+12
View File
@@ -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 })
})
+5 -5
View File
@@ -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<BrowseResult>(`/api/filesystem/browse${qs ? `?${qs}` : ''}`)
return api.get<BrowseResult>(`/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<BrowseResult>(`/api/filesystem/browse?${q}`)
return api.get<BrowseResult>(`/api/filesystem/browse?${q}`, options)
},
}
+3 -2
View File
@@ -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', {})
})
+2 -2
View File
@@ -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<SessionCollaborationStatus>(`${base}/${encodeURIComponent(sessionId)}/status`) },
stop(sessionId: string) { return api.post(`${base}/${encodeURIComponent(sessionId)}/stop`, {}) },
}
@@ -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' })
@@ -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()
})
})
@@ -80,7 +80,7 @@ it('browses directory queries without filtering out their children', async () =>
const onSelect = vi.fn()
render(<ComposerReferenceMenu id="path" cwd="/work" filter="src/" references={references} onSelect={onSelect} />)
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<ComposerReferenceMenuHandle>()
const view = render(<ComposerReferenceMenu ref={ref} id="search" cwd="/old" filter="old" references={[]} onSelect={onSelect} />)
await waitFor(() => expect(filesystemApi.search).toHaveBeenCalled())
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" filter="app" references={[]} onSelect={onSelect} />)
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(<ComposerReferenceMenu ref={ref} id="pending" cwd="/work" filter="hyper" references={references} onSelect={onSelect} />)
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(<ComposerReferenceMenu id="directory" cwd="/work" filter={filter} references={[]} onSelect={vi.fn()} />)
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(<ComposerReferenceMenu id="sessions" cwd="/work" filter="Auth" references={[]} onSelect={onSelect} />)
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<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
const view = render(<ComposerReferenceMenu ref={ref} id="switch-query" cwd="/work" filter="old" references={[]} onSelect={onSelect} />)
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(<ComposerReferenceMenu ref={ref} id="switch-query" cwd="/work" filter="Review" references={[]} onSelect={onSelect} />)
@@ -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(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修" references={[]} onSelect={vi.fn()} />)
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
view.rerender(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修复" references={[]} onSelect={vi.fn()} />)
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(<ComposerReferenceMenu id="cancel" cwd="/work" filter="修复中" references={[]} onSelect={vi.fn()} />)
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(<ComposerReferenceMenu id="browse-cancel" cwd="/work" filter="src/" references={[]} onSelect={vi.fn()} />)
expect(filesystemApi.browse).toHaveBeenCalledTimes(1)
const signal = vi.mocked(filesystemApi.browse).mock.calls[0]![1]!.signal!
view.rerender(<ComposerReferenceMenu id="browse-cancel" cwd="/other" filter="src/" references={[]} onSelect={vi.fn()} />)
expect(signal.aborted).toBe(true)
expect(filesystemApi.browse).toHaveBeenCalledTimes(2)
})
@@ -44,14 +44,15 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
useEffect(() => {
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<ComposerReferenceMenuHandle, Pro
useEffect(() => {
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(() => {
+1 -1
View File
@@ -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()
@@ -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
+2 -2
View File
@@ -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<Response> {
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))
@@ -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 {
@@ -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')
@@ -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<void>
stop(): Promise<void>
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 ||
@@ -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() }
})
@@ -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<ReadinessRow>(
'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)
@@ -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<SessionRow>(`
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<SessionRow>(`
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
@@ -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'])
})
@@ -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()
})
+19
View File
@@ -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<number>,
@@ -231,6 +233,7 @@ function isAbortError(error: unknown): boolean {
}
export class SearchService {
private readonly suggestIndexedSessions: NonNullable<SearchServiceOptions['suggestIndexedSessions']>
private readonly readEntriesAtLines: NonNullable<SearchServiceOptions['readEntriesAtLines']>
private readonly getMetadataForPaths: NonNullable<SearchServiceOptions['getMetadataForPaths']>
private readonly getCandidatesForFilters: NonNullable<SearchServiceOptions['getCandidatesForFilters']>
@@ -239,6 +242,7 @@ export class SearchService {
private readonly commandAvailability = new Map<string, Promise<boolean>>()
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.
*
@@ -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
}
+29 -42
View File
@@ -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<Array<Record<string, unknown>>> {
const needle = query.toLocaleLowerCase()
const matches = new Map<string, Record<string, unknown>>()
const seen = new Set<string>()
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<void> } | undefined
let unsubscribe: (() => void) | undefined
@@ -62,27 +40,36 @@ export async function getSessionCollaborationService(): Promise<SessionCollabora
const service = new SessionCollaborationService({
statePath: path,
sessions: {
async list({ query, limit, offset }) {
if (!query?.trim()) return sessionService.listSessions({ limit, offset })
// Search transcript content as well as titles, using the existing
// indexed/bounded fallback instead of materializing all histories.
const [matches, metadata] = await Promise.all([
searchService.searchSessions(query, { limit: Math.min(100, offset + limit), matchesPerSession: 1 }),
findSessionMetadataMatches(query, options => sessionService.listSessions(options)),
])
const selected = new Map<string, Record<string, unknown>>()
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<string, unknown>): 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<string>()
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)),
@@ -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' })
@@ -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<unknown>
list(options: { query?: string; limit: number; offset: number; signal?: AbortSignal }): Promise<unknown>
read(sessionId: string, options: { cursor?: string; limit: number; signal?: AbortSignal }): Promise<unknown>
exists(sessionId: string): Promise<boolean>
/** 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<unknown> {
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<unknown> {
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<Record<string, unknown>> }
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<Record<string, unknown>> }
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 ?? ''),
})) }
}
@@ -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([])
})
+48
View File
@@ -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