fix(web): align session search contracts
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-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 镜像
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {} }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -213,6 +213,10 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchTree > [role='treeitem'] + [role='treeitem'] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.searchStatus,
|
||||
.searchWarning {
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 08eeddaf9ec8cba315d0cc85b41750005bec53c0
|
||||
README.zh.md: 984e1a340298afae48799a2b09cab8583d9c780a
|
||||
README.md: 31a41a945e11a610f2c67c0ccf89a4d2677a1067
|
||||
README.zh.md: b78f7b19b0bde51327f09f282ef84ab087fab25b
|
||||
|
||||
@@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points; a malformed non-string provider snippet fails closed at the Host, and the response schema independently rejects an oversized snippet at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches.
|
||||
|
||||
@@ -39,3 +39,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
|
||||
@@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点;如果提供方返回格式错误的非字符串 snippet,系统会在宿主侧直接失败,响应 schema 则会在每个客户端边界独立拒绝超长的 snippet。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
@@ -39,3 +39,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
|
||||
@@ -24,6 +24,11 @@ import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem,
|
||||
SessionSummary, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './api/session-search.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
@@ -38,15 +43,9 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
|
||||
/** Product contract: snippets contain at most 240 Unicode code points. */
|
||||
const SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT = 240
|
||||
|
||||
/** Bound cold-log stat fan-out so an aborted search stops launching new work. */
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
|
||||
@@ -58,28 +57,6 @@ function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/** Copy at most the product-visible code-point prefix without splitting a surrogate pair. */
|
||||
function boundedSessionSearchSnippet(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('session search provider returned a non-string snippet')
|
||||
}
|
||||
let end = 0
|
||||
for (
|
||||
let count = 0;
|
||||
count < SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT && end < value.length;
|
||||
count++
|
||||
) {
|
||||
const first = value.charCodeAt(end)
|
||||
const hasSurrogatePair = first >= 0xD800
|
||||
&& first <= 0xDBFF
|
||||
&& end + 1 < value.length
|
||||
&& value.charCodeAt(end + 1) >= 0xDC00
|
||||
&& value.charCodeAt(end + 1) <= 0xDFFF
|
||||
end += hasSurrogatePair ? 2 : 1
|
||||
}
|
||||
return end === value.length ? value : value.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
@@ -683,8 +660,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const seenCursors = new Set<SessionSearchCursor>()
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
let providerCallCount = 0
|
||||
let providerPageLimit = SESSION_SEARCH_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_LIMIT) {
|
||||
let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
|
||||
throw new Error(
|
||||
@@ -739,14 +716,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Host visibility is the authorization boundary. Consume the
|
||||
// provider's globally ranked stream rather than binding every
|
||||
// visible id into one SQLite statement, then re-check complete
|
||||
// provenance before emitting any snippet. Inspect exactly the
|
||||
// declared array entries so a custom iterator cannot overproduce.
|
||||
for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) {
|
||||
const hit = page.items[itemIndex]
|
||||
if (hit === undefined) {
|
||||
throw new Error(`session search provider omitted item at index ${itemIndex}`)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_LIMIT) continue
|
||||
// provenance before emitting any snippet.
|
||||
for (const hit of page.items) {
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
|
||||
if (
|
||||
!visibleIds.has(hit.header.id)
|
||||
|| hit.bestMatch.sessionId !== hit.header.id
|
||||
@@ -754,7 +726,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|
||||
|| acceptedIds.has(hit.header.id)
|
||||
) continue
|
||||
const snippet = boundedSessionSearchSnippet(hit.bestMatch.snippet)
|
||||
const snippet = truncateUnicodeCodePoints(
|
||||
hit.bestMatch.snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
)
|
||||
acceptedIds.add(hit.header.id)
|
||||
authorized.push({
|
||||
sessionId: hit.header.id,
|
||||
@@ -768,18 +743,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
seenCursors.add(nextCursor)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
return ok(request, {
|
||||
items: authorized.slice(0, SESSION_SEARCH_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_LIMIT,
|
||||
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isAborted(signal)
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
|
||||
) return cancelled()
|
||||
// XXX: Redact provider details before exposing this gateway beyond
|
||||
// its current single-user local deployment.
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session search failed: ${String(error)}`,
|
||||
|
||||
@@ -51,5 +51,11 @@ export type {
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
} from './session-search.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'
|
||||
|
||||
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
22
packages/host/apiproxy/src/api/session-search.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Maximum number of sessions returned by one sidebar search. */
|
||||
export const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** Maximum snippet length in Unicode code points. */
|
||||
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/**
|
||||
* Return the longest prefix containing at most `maximum` Unicode code points.
|
||||
* @param value - text to bound.
|
||||
* @param maximum - non-negative code-point limit.
|
||||
* @returns `value` unchanged when it fits, otherwise a code-point-safe prefix.
|
||||
*/
|
||||
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
|
||||
let count = 0
|
||||
let end = 0
|
||||
for (const codePoint of value) {
|
||||
if (count === maximum) return value.slice(0, end)
|
||||
count++
|
||||
end += codePoint.length
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
truncateUnicodeCodePoints,
|
||||
} from './session-search.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
@@ -56,28 +61,6 @@ export const sessionListValueSchema = z.object({
|
||||
|
||||
/** Fixed wire bound for one interactive sidebar query. */
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
/** Product response bound validated independently by every client carrier. */
|
||||
const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
/** Maximum response snippet length in Unicode code points. */
|
||||
const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/** Early-exit Unicode code-point bound without materializing an iterator result. */
|
||||
function hasAtMostCodePoints(value: string, maximum: number): boolean {
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while (offset < value.length) {
|
||||
if (count === maximum) return false
|
||||
const first = value.charCodeAt(offset)
|
||||
const paired = first >= 0xD800
|
||||
&& first <= 0xDBFF
|
||||
&& offset + 1 < value.length
|
||||
&& value.charCodeAt(offset + 1) >= 0xDC00
|
||||
&& value.charCodeAt(offset + 1) <= 0xDFFF
|
||||
offset += paired ? 2 : 1
|
||||
count++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** session.search request payload. */
|
||||
export const sessionSearchRequestSchema = z.object({
|
||||
@@ -89,7 +72,10 @@ export const sessionSearchRequestSchema = z.object({
|
||||
export const sessionSearchItemSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
snippet: z.string().refine(
|
||||
snippet => hasAtMostCodePoints(snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
|
||||
snippet => truncateUnicodeCodePoints(
|
||||
snippet,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
) === snippet,
|
||||
{ message: `search snippet must contain at most ${SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS} Unicode code points` },
|
||||
),
|
||||
}) satisfies z.ZodType<Wire<SessionSearchItem>>
|
||||
|
||||
@@ -536,12 +536,10 @@ describe('session.search', () => {
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an oversized provider page before iterating its items', async () => {
|
||||
it('rejects an oversized provider page', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = new Array<SessionSearchHit>(21)
|
||||
const iterate = vi.fn(() => oversized.values())
|
||||
Object.defineProperty(oversized, Symbol.iterator, { value: iterate })
|
||||
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
@@ -554,15 +552,12 @@ describe('session.search', () => {
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 21 items; maximum is 20')
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the learned provider limit for the overproduction guard', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const oversized = new Array<SessionSearchHit>(11)
|
||||
const iterate = vi.fn(() => oversized.values())
|
||||
Object.defineProperty(oversized, Symbol.iterator, { value: iterate })
|
||||
const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (providerRequest.limit === 20) {
|
||||
return Promise.reject(new SessionQueryError(
|
||||
@@ -584,7 +579,6 @@ describe('session.search', () => {
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
|
||||
@@ -617,58 +611,6 @@ describe('session.search', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the provider returns a non-string snippet', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({
|
||||
items: [{
|
||||
...visible,
|
||||
bestMatch: { ...visible.bestMatch, snippet: 42 },
|
||||
}],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('malformed-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toContain('non-string snippet')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
})
|
||||
|
||||
it('inspects only numerically stored items when a compliant page overrides iteration', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
for (const item of visible) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const stored = visible.slice(0, 1)
|
||||
const iterate = vi.fn(() => visible.values())
|
||||
Object.defineProperty(stored, Symbol.iterator, { value: iterate })
|
||||
const searchSessions = vi.fn(() => Promise.resolve({ items: stored }))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('custom-iterator'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible-0', snippet: 'match 0' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the provider repeats a continuation cursor', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
|
||||
@@ -628,6 +628,8 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
offset,
|
||||
]
|
||||
assertPortableBindingCount(bindings.length)
|
||||
// The browser fixture mirrors these rank keys in
|
||||
// `packages/client/connection/src/client/fixture.ts`; update both together.
|
||||
return this._requireDb().prepare(`
|
||||
${selected.sql},
|
||||
filtered AS (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
/** Current derived-index schema version. Incompatible versions reset in place. */
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6
|
||||
|
||||
/** SQLite application id protecting unrelated databases from derived resets. */
|
||||
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
|
||||
|
||||
@@ -289,6 +289,34 @@ describe('SQLite session search', () => {
|
||||
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
|
||||
})
|
||||
|
||||
it('excludes assistant reasoning while indexing visible answer text', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('reasoning'))
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'private-chain-marker' },
|
||||
{ type: 'text', text: 'visible-answer-marker' },
|
||||
],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'private-chain-marker' }))
|
||||
.resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'visible-answer-marker' }))
|
||||
.resolves.toMatchObject({
|
||||
items: [{
|
||||
header: { id: session.id },
|
||||
bestMatch: { snippet: 'visible-answer-marker' },
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('searches all surfaces by default and applies metadata before ranking', async () => {
|
||||
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 })
|
||||
const parent = SessionId('parent')
|
||||
@@ -1190,7 +1218,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
|
||||
const staleOwner = await liveContext({ path: stalePath })
|
||||
await (staleOwner.sessionQuery as SessionQuerySqlite).close()
|
||||
const stale = new DatabaseSync(stalePath)
|
||||
stale.exec('PRAGMA user_version = 999')
|
||||
stale.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION - 1}`)
|
||||
stale.close()
|
||||
const staleCtx = await liveContext({ path: stalePath })
|
||||
staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: ebc577975f874a1a60c84061f9148282742bfdf2
|
||||
README.zh.md: c4d0c27c846bad6b9b621b6db391be1d4ee69fed
|
||||
# pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md
|
||||
README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063
|
||||
README.zh.md: cc79a6f48b4c997a4e940f99aaab169291e2b900
|
||||
|
||||
@@ -23,7 +23,7 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi
|
||||
|
||||
`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`.
|
||||
|
||||
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
|
||||
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; reasoning blocks, structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
|
||||
|
||||
## Full-text methods
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
`SessionResultFilter` 覆盖 id、可空 cwd、创建时间范围、可空父级和来源可用性。`SessionEventResultFilter` 覆盖 seq/时间范围、事件类型、接口和语义文本。过滤器数组使用 AND;同一列表子句内的值使用 OR。空列表值不匹配任何内容,范围包含端点,而格式错误的范围或封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。
|
||||
|
||||
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()` 和 `buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
|
||||
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()` 和 `buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;推理(reasoning)块、结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
|
||||
|
||||
## 全文方法
|
||||
|
||||
|
||||
@@ -75,8 +75,9 @@ function contentText(content: readonly SessionContentBlock[]): string {
|
||||
function blockText(block: SessionContentBlock): 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':
|
||||
|
||||
@@ -54,8 +54,20 @@ describe('session-query semantic extraction', () => {
|
||||
]
|
||||
|
||||
for (const event of events.slice(0, 4)) {
|
||||
expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested')
|
||||
expect(extractSessionEventText(event)).toBe('visible\nread\n{"path":"a"}\nnested')
|
||||
}
|
||||
expect(extractSessionEventText({
|
||||
type: 'assistant/message',
|
||||
seq: 9,
|
||||
time: 10,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'reasoning', text: 'private thought' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
})).toBe('')
|
||||
expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy')
|
||||
expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}')
|
||||
expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS')
|
||||
|
||||
Reference in New Issue
Block a user