feat(web): add basic past-session search (round 1)

This commit is contained in:
Hypatia May
2026-07-27 12:15:06 +08:00
parent 79eb3a9035
commit 891e9035e7
56 changed files with 1646 additions and 269 deletions

View File

@@ -0,0 +1,237 @@
/**
* Host session.search projection: list-equivalent visibility, fixed message
* filters and result bound, cancellation mapping, and unavailable/failure
* behavior.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import {
SessionQueryError,
type SessionSearchHit,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (value: string): SessionId => value as SessionId
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }
}
function header(id: string, cwd: string | null = '/project'): SessionHeader {
return {
version: 0,
id: sid(id),
createdAt: 100,
...(cwd === null ? {} : { cwd }),
}
}
function hit(id: string, index = 0): SessionSearchHit {
const session = header(id)
return {
header: session,
live: true,
persisted: false,
bestMatch: {
sessionId: session.id,
seq: index,
type: 'user/message',
time: 200 + index,
surface: 'current',
snippet: `match ${index}`,
},
}
}
async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
return ctx
}
describe('session.search', () => {
it('searches only list-visible ids and current conversation-message events', async () => {
const ctx = await baseContext()
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
live.append('user/message', {
content: [{ type: 'text', text: 'live text' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const cold = header('cold', '/cold')
const legacy = header('legacy', null)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([cold, legacy]),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((
_request: SessionSearchRequest,
_exec?: { signal?: AbortSignal },
) => Promise.resolve({
items: [
{
header: legacy,
live: false,
persisted: true,
bestMatch: {
sessionId: legacy.id,
seq: 3,
type: 'user/message' as const,
time: 190,
surface: 'current' as const,
snippet: 'must remain hidden',
},
},
{
header: cold,
live: false,
persisted: true,
bestMatch: {
sessionId: cold.id,
seq: 4,
type: 'assistant/message' as const,
time: 200,
surface: 'current' as const,
snippet: 'the matching answer',
},
},
],
nextCursor: 'more' as never,
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const signal = new AbortController().signal
const response = await api.sessions.search(request('matching answer'), signal)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
hasMore: true,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
SessionSearchRequest,
{ signal: AbortSignal },
]
expect(query).toEqual({
query: 'matching answer',
sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }],
eventFilters: [
{
kind: 'type',
values: ['user/message', 'assistant/message', 'steering/message'],
},
{ kind: 'surface', values: ['current'] },
],
limit: 20,
})
expect(exec.signal).toBe(signal)
})
it('returns an empty page without invoking the index when no session is visible', async () => {
const ctx = await baseContext()
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const response = await api.sessions.search(
request('anything'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
expect(searchSessions).not.toHaveBeenCalled()
})
it('enforces the 20-item Host boundary even if a provider overproduces', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items }),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('match'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items).toHaveLength(20)
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
})
it('maps missing composition, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
const missingApi = createApiProxy(missingCtx, defaults)
const preAborted = new AbortController()
preAborted.abort()
const cancelledBeforeLookup = await missingApi.sessions.search(
request('cancel-before-lookup'),
preAborted.signal,
)
expect(cancelledBeforeLookup.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const missing = await missingApi.sessions.search(
request('needle'),
new AbortController().signal,
)
expect(missing.result.ok).toBe(false)
if (missing.result.ok) throw new Error('unreachable')
expect(missing.result.error.code).toBe('internal')
expect(missing.result.error.message).toContain('does not mount')
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
const searchSessions = vi.fn()
.mockRejectedValueOnce(aborted)
.mockRejectedValueOnce(new Error('database unavailable'))
ctx.provide('sessionQuery', { searchSessions } as never)
const api = createApiProxy(ctx, defaults)
const cancelled = await api.sessions.search(
request('first'),
new AbortController().signal,
)
expect(cancelled.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const failed = await api.sessions.search(
request('second'),
new AbortController().signal,
)
expect(failed.result.ok).toBe(false)
if (failed.result.ok) throw new Error('unreachable')
expect(failed.result.error.code).toBe('internal')
expect(failed.result.error.message).toContain('database unavailable')
})
})

View File

@@ -29,6 +29,7 @@ function scriptedApi(overrides: {
return {
sessions: {
list: r => ok(r, { items: [] }),
search: r => ok(r, { items: [], hasMore: false }),
create: r => ok(r, { sessionId: sid('s-new') }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { accepted: true as const }),
@@ -76,6 +77,30 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
})
it('round-trips a trimmed session search query and its bounded result metadata', async () => {
let seen: RpcRequest<{ query: string }> | undefined
const api = scriptedApi({
sessions: {
search: (request) => {
seen = request
return ok(request, {
items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
hasMore: true,
})
},
},
})
const response = await client(api).sessions.search({ query: ' message text ' })
expect(seen?.payload).toEqual({ query: 'message text' })
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching message text' }],
hasMore: true,
},
})
})
it('routes workspace rename and insertSessionBefore through the wire', async () => {
const api = scriptedApi()
const c = client(api)

View File

@@ -21,6 +21,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
},
async search(request, signal) {
if (request.payload.query === 'hang') {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false },
},
}
},
async create(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
},
@@ -124,6 +144,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
})
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
@@ -155,6 +179,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.rpcId).toBe('r-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into session.search', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-search-sig',
method: 'session.search',
payload: { query: 'hang' },
})
const pending = handler.fetch(new Request(
'http://x/api/session.search',
{ method: 'POST', body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-search-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
})
describe('handler carrier-layer statuses', () => {

View File

@@ -9,7 +9,7 @@ import {
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
sessionPromptValueSchema, sessionSummarySchema,
sessionPromptValueSchema, sessionSearchRequestSchema, sessionSearchValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
@@ -121,6 +121,28 @@ describe('sessions domain schemas', () => {
expect(sessionListRequestSchema.parse({})).toEqual({})
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' })
expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow()
expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/)
expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow()
expect(sessionSearchValueSchema.parse({
items: [{ sessionId: 's1', snippet: 'matching text' }],
hasMore: true,
})).toEqual({
items: [{ sessionId: 's1', snippet: 'matching text' }],
hasMore: true,
})
expect(() => sessionSearchValueSchema.parse({
items: [{ sessionId: '', snippet: 'matching text' }],
hasMore: false,
})).toThrow()
expect(() => sessionSearchValueSchema.parse({
items: Array.from(
{ length: 21 },
(_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }),
),
hasMore: true,
})).toThrow()
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')