fix(web): align session search contracts

This commit is contained in:
Hypatia May
2026-07-27 18:38:00 +08:00
parent 621f414407
commit 4727d742db
42 changed files with 235 additions and 196 deletions

View File

@@ -1,7 +1,7 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
@@ -19,7 +19,11 @@ export type {
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
export {
RpcId,
SESSION_SEARCH_RESULT_LIMIT,
transportError,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'

View File

@@ -14,7 +14,7 @@ import type {
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
@@ -302,8 +302,9 @@ function pageOf(
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
case 'text':
case 'reasoning':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
@@ -425,7 +426,7 @@ interface FixtureSearchCandidate {
documentLength: number
}
/** Same rank keys as session-query-sqlite's cross-session result order. */
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
@@ -741,11 +742,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return best === undefined ? [] : [best]
}).sort(compareSearchCandidates)
return ok(request, {
items: matches.slice(0, 20).map(match => ({
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > 20,
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
})
},
create: async (request) => {

View File

@@ -5,6 +5,7 @@
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
import { SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
@@ -19,7 +20,12 @@ export type {
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
export {
RpcId,
SESSION_SEARCH_RESULT_LIMIT,
AbstractApiClient,
transportError,
} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
@@ -37,6 +43,8 @@ export const inject: string[] = []
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/** Protocol-owned maximum rows for one session-search response. */
readonly sessionSearchResultLimit: number
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -58,6 +66,7 @@ export function apply(ctx: Context): void {
let started = false
const handle: ConnectionHandle = {
api,
sessionSearchResultLimit: SESSION_SEARCH_RESULT_LIMIT,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -89,6 +89,11 @@ describe('createFixtureApi', () => {
ok: true,
value: { items: [], hasMore: false },
})
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
expect(reasoningOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const aborted = new AbortController()
aborted.abort()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 8a83a7c377e53a19e6d22b49219ba2ed3441cf3c
README.zh.md: 007eb546a922f5711fc14d44c299627c76bf0776
README.md: aed0b21829e06cf67486084101d2f8c016264ab8
README.zh.md: 4f5c907c378094330dee777b2efa369c3c5a49c1

View File

@@ -12,7 +12,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` exposes the protocol-owned page bound as injected presentation data, so client plugins do not duplicate it.
## New Session and the blank mirror

View File

@@ -12,7 +12,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将协议定义的分页上限作为注入的呈现数据公开,使客户端插件无需复制该值。
## New Session 与 blank 镜像

View File

@@ -113,7 +113,11 @@ export const inject = ['connection']
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const sessions = new SessionsService(
ctx,
connection.api,
connection.sessionSearchResultLimit,
)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),

View File

@@ -149,6 +149,8 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** Fixed sidebar result bound supplied to presentation plugins as injected data. */
readonly searchResultLimit: number
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -182,8 +184,14 @@ export class SessionsService {
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
* @param searchResultLimit - protocol-owned search bound from the connection service.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(
private readonly rootCtx: Context,
api: IApiClient,
searchResultLimit: number,
) {
this.searchResultLimit = searchResultLimit
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })

View File

@@ -25,6 +25,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
sessionSearchResultLimit: 7,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
@@ -50,6 +51,7 @@ describe('runtime client apply', () => {
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
expect((sessions as SessionsService).searchResultLimit).toBe(7)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()

View File

@@ -23,7 +23,7 @@ interface Bench {
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const svc = new SessionsService(ctx, api)
const svc = new SessionsService(ctx, api, 20)
return { ctx, api, svc }
}

View File

@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
sessionSearchResultLimit: 20,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }

View File

@@ -124,7 +124,7 @@ describe('WorkspacesService', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, 20)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
@@ -152,7 +152,7 @@ describe('WorkspacesService', () => {
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, 20)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha'), workspace('beta')] as never[],
@@ -188,7 +188,7 @@ describe('WorkspacesService', () => {
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, 20)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
api.onList = () => Promise.resolve(ok({
@@ -208,7 +208,7 @@ describe('WorkspacesService', () => {
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, 20)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
@@ -221,7 +221,7 @@ describe('WorkspacesService', () => {
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const sessions = new SessionsService(ctx, api, 20)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
await workspaces.refresh()

View File

@@ -94,7 +94,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
}) as never)
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
const sessions = new SessionsService(ctx, api, 20) // provides 'sessions' itself
await sessions.refresh()
await Promise.resolve() // manager notifier flush
await ctx.plugin(SlashService).await()

View File

@@ -213,6 +213,10 @@
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchStatus,
.searchWarning {
padding: 10px 12px;

View File

@@ -261,33 +261,37 @@ function SearchResults({
workspaces,
query,
remote,
resultLimit,
}: Pick<SessionTreeProps, 'useSessions' | 'open'> & {
workspaces: readonly WorkspaceView[]
query: string
remote: RemoteSearchState
resultLimit: number
}) {
const list = useSessions((s) => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(list, workspaces, query, currentRemote),
[list, workspaces, query, currentRemote],
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
[list, workspaces, query, currentRemote, resultLimit],
)
const pending = currentRemote.status === 'loading'
const failed = currentRemote.status === 'error'
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Search results">
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
<div className={css.list}>
<div className={css.searchTree} role="tree" aria-label="Search results">
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
</div>
{pending && (
<div className={css.searchStatus} role="status">Searching session history</div>
)}
@@ -300,7 +304,9 @@ function SearchResults({
<div className={css.empty}>No matching sessions</div>
)}
{results.hasMore && (
<div className={css.searchStatus}>Showing the first 20 results. Narrow your search.</div>
<div className={css.searchStatus}>
Showing the first {resultLimit} results. Narrow your search.
</div>
)}
</div>
<span className={css.fade} />
@@ -327,6 +333,7 @@ export function WorkspaceBrowser({
insertSessionBefore,
createWorkspace,
searchSessions,
searchResultLimit,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -544,6 +551,7 @@ export function WorkspaceBrowser({
workspaces={workspaces}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
/>
)
: groupBy === 'flat'

View File

@@ -40,6 +40,8 @@ export type WorkspaceBrowserInjected = {
query: string,
signal: AbortSignal,
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
/** Maximum number of merged rows rendered for one search. */
searchResultLimit: number
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -44,6 +44,7 @@ export function apply(ctx: ClientContext): void {
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
searchSessions,
searchResultLimit: ctx.sessions.searchResultLimit,
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

View File

@@ -278,9 +278,6 @@ export function deriveFlat(list: SessionListState): SessionNode[] {
return rows.map(s => sessionNode(s, [], false, false))
}
/** Maximum rows rendered by the basic search surface. */
const SEARCH_RESULT_LIMIT = 20
/**
* Merge immediate title/Workspace substring matches with ranked Host content
* matches. Local rows lead newest-first, content-only rows retain backend
@@ -289,13 +286,15 @@ const SEARCH_RESULT_LIMIT = 20
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param content - ranked Host content-search page.
* @returns at most 20 deduplicated flat rows and a refine-query hint bit.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
*/
export function deriveSearchResults(
list: SessionListState,
workspaces: readonly WorkspaceView[],
query: string,
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
@@ -340,7 +339,7 @@ export function deriveSearchResults(
}
return {
items: ordered.slice(0, SEARCH_RESULT_LIMIT).map((summary) => {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
return {
id: summary.id,
@@ -350,7 +349,7 @@ export function deriveSearchResults(
...match === undefined ? {} : { snippet: match.snippet },
}
}),
hasMore: content.hasMore || ordered.length > SEARCH_RESULT_LIMIT,
hasMore: content.hasMore || ordered.length > limit,
}
}

View File

@@ -26,7 +26,7 @@ async function bench() {
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, search } as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
return {
ctx,
slots: ctx.get('slots') as SlotsService,
@@ -86,6 +86,7 @@ describe('ui-workspace apply', () => {
hasMore: false,
})
expect(b.search).toHaveBeenCalledWith('match', signal)
expect(browser.searchResultLimit).toBe(20)
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)

View File

@@ -167,6 +167,7 @@ describe('deriveSearchResults', () => {
],
hasMore: false,
},
10,
)
expect(result).toEqual({
@@ -214,6 +215,7 @@ describe('deriveSearchResults', () => {
],
hasMore: false,
},
10,
)
expect(result.items).toEqual([{
id: currentBlank.id,
@@ -224,14 +226,20 @@ describe('deriveSearchResults', () => {
}])
})
it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => {
const rows = Array.from({ length: 22 }, (_, index) => {
it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
const rows = Array.from({ length: 5 }, (_, index) => {
const item = summary(`s-${String(index).padStart(2, '0')}`, index)
item.displayTitle = `Needle ${String(index)}`
return item
})
const overflow = deriveSearchResults(list(...rows), [], 'needle', { items: [], hasMore: false })
expect(overflow.items).toHaveLength(20)
const overflow = deriveSearchResults(
list(...rows),
[],
'needle',
{ items: [], hasMore: false },
3,
)
expect(overflow.items).toHaveLength(3)
expect(overflow.hasMore).toBe(true)
const backendMore = deriveSearchResults(
@@ -239,10 +247,11 @@ describe('deriveSearchResults', () => {
[],
'needle',
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
3,
)
expect(backendMore.items).toHaveLength(1)
expect(backendMore.hasMore).toBe(true)
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }))
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
.toEqual({ items: [], hasMore: false })
})
})

View File

@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
searchResultLimit: 20,
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
@@ -217,10 +218,12 @@ describe('WorkspaceBrowser', () => {
})
const input = screen.getByPlaceholderText<HTMLInputElement>('Search names or content…')
fireEvent.change(input, { target: { value: 'needle' } })
expect(screen.getByRole('tree', { name: 'Search results' })).toBeTruthy()
const resultTree = screen.getByRole('tree', { name: 'Search results' })
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
expect(screen.getByText('Searching session history…')).toBeTruthy()
const status = screen.getByRole('status')
expect(status.textContent).toBe('Searching session history…')
expect(resultTree.contains(status)).toBe(false)
fireEvent.change(input, { target: { value: 'zzz' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })