Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-31 11:39:58 +08:00
190 changed files with 4231 additions and 770 deletions

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/connection/README.md
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45

View File

@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -1,12 +1,12 @@
// 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.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,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

@@ -26,13 +26,14 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
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> {
@@ -575,6 +576,144 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
case 'text':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
return block.content.flatMap(searchBlockText)
default:
return []
}
}
/** One current-surface user/assistant/steering document, if searchable. */
function searchEventText(event: SessionEvent): string {
const content = event.type === 'user/message'
? event.data.content
: event.type === 'assistant/message' || event.type === 'steering/message'
? event.data.message.content
: undefined
if (content === undefined) return ''
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
}
interface FixtureSearchToken {
value: string
/** Inclusive code-point offset in the whitespace-normalized display text. */
start: number
/** Exclusive code-point offset in the whitespace-normalized display text. */
end: number
}
/**
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
* Keeping phrase matching token-based prevents the development fixture from
* promising arbitrary within-token substring behavior that production lacks.
*/
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
const text = value.replace(/\s+/gu, ' ').trim()
const characters = Array.from(text)
const tokens: FixtureSearchToken[] = []
let start: number | undefined
let raw = ''
const flush = (end: number): void => {
if (start !== undefined) {
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
if (folded !== '') tokens.push({ value: folded, start, end })
}
start = undefined
raw = ''
}
for (let index = 0; index < characters.length; index++) {
const character = characters[index] as string
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
if (tokenBase === '') {
if (start !== undefined) raw += character
continue
}
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
start ??= index
raw += character
} else {
flush(index)
}
}
flush(characters.length)
return { text, tokens }
}
interface FixturePhraseMatch {
count: number
start: number
end: number
}
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
let count = 0
let firstStart = 0
let firstEnd = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
count++
if (count === 1) {
firstStart = document[start]?.start ?? 0
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
}
}
return { count, start: firstStart, end: firstEnd }
}
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
const characters = Array.from(value)
if (characters.length <= 120) return value
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
const boundedEnd = Math.min(
characters.length,
Math.max(boundedStart + 1, matchEnd),
)
const center = Math.floor((boundedStart + boundedEnd) / 2)
let start = Math.min(
characters.length - 118,
Math.max(0, center - Math.floor(118 / 2)),
)
let end = start + 118
if (start === 0) {
end = 119
} else if (end === characters.length) {
start = characters.length - 119
}
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
}
interface FixtureSearchCandidate {
sessionId: SessionId
seq: number
time: number
text: string
matchCount: number
matchStart: number
matchEnd: number
documentLength: number
}
/** 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
if (a.time !== b.time) return b.time - a.time
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
return b.seq - a.seq
}
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
@@ -1072,6 +1211,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
search: (request, signal) => {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'fixture session search was aborted',
details: {},
})
}
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
const matches = sessions.flatMap((summary) => {
const log = logs.get(summary.sessionId) ?? []
const current = new Set(foldSurface(log).nodes)
const best = log.flatMap((event): FixtureSearchCandidate[] => {
if (!current.has(event.seq)) return []
const eventText = searchEventText(event)
const document = searchTokenSpans(eventText)
const match = phraseMatch(document.tokens, query)
if (match.count === 0) return []
return [{
sessionId: summary.sessionId,
seq: event.seq,
time: event.time,
text: document.text,
matchCount: match.count,
matchStart: match.start,
matchEnd: match.end,
documentLength: Array.from(eventText).length,
}]
}).sort(compareSearchCandidates)[0]
return best === undefined ? [] : [best]
}).sort(compareSearchCandidates)
return ok(request, {
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
})
},
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
@@ -1776,20 +1954,30 @@ export class FixtureApiClient extends AbstractApiClient {
protected override async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
const response = await this.dispatch(
method,
request as RpcRequest<never>,
signal ?? new AbortController().signal,
) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
private dispatch(
method: keyof RpcMethodMap,
request: RpcRequest<never>,
signal: AbortSignal,
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
case 'session.search': return this.api.sessions.search(request, signal)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
@@ -1810,8 +1998,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)

View File

@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
export {
RpcId,
AbstractApiClient,
transportError,
} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),

View File

@@ -52,6 +52,59 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('searches current message text with literal unicode61-style token phrases', async () => {
const api = createFixtureApi()
const signal = new AbortController().signal
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
expect(phrase.result).toMatchObject({
ok: true,
value: {
items: [{ sessionId: 'fx-alpha' }],
hasMore: false,
},
})
if (!phrase.result.ok) throw new Error('search failed')
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
timing().appendUser(
'fx-alpha',
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
)
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
if (!late.result.ok) throw new Error('late search failed')
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
expect(lateSnippet).toContain('late café token')
expect(lateSnippet.startsWith('…')).toBe(true)
expect(lateSnippet.endsWith('…')).toBe(true)
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
expect(substring.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
expect(punctuationOnly.result).toEqual({
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()
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
})
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -833,6 +886,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
expect((await client.sessions.search(
{ query: 'fixture' },
new AbortController().signal,
)).result.ok).toBe(true)
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId

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/modules/README.md
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

View File

@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.

View File

@@ -8,6 +8,8 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

View File

@@ -58,6 +58,47 @@ interface PkgMeta {
immediately: boolean
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
/** Missing built client export, retained as structured data for activation-error grouping. */
class MissingClientBundleError extends Error {
constructor(
readonly packageName: string,
readonly clientPath: string,
cause: unknown,
) {
super(
[
`client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
` package: ${packageName}`,
` path: ${clientPath}`,
].join('\n'),
{ cause },
)
}
}
/** Activation failures grouped by actionable package-build errors and unrelated failures. */
class ClientPackageCompositionError extends AggregateError {
constructor(failures: Error[]) {
const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
const packageNoun = failures.length === 1 ? 'package' : 'packages'
const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
if (missingBundles.length > 0) {
lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
for (const error of missingBundles) {
lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
}
}
if (otherFailures.length > 0) {
lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
}
super(failures, lines.join('\n'))
}
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot sweep reports it).
* boot activation audit reports it).
*/
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
throw new AggregateError(
failures,
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
throw new ClientPackageCompositionError(failures)
}
ctx.effect(
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
return meta
}
/**
* Read the activation-time bundle revision.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the bundle content's short hash for use as its revision.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
private initialBundleRevision(pkgName: string, clientPath: string): string {
try {
return shortHash(readFileSync(clientPath))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new MissingClientBundleError(pkgName, clientPath, error)
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = shortHash(readFileSync(meta.clientPath))
const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}

View File

@@ -0,0 +1,87 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
afterEach(() => {
if (root !== undefined) rmSync(root, { recursive: true, force: true })
root = undefined
})
/** Create a resolvable dshClient package whose client export points at the returned path. */
function writePackage(packageName: string): string {
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
const clientPath = join(pkgRoot, 'lib', 'client.js')
mkdirSync(pkgRoot, { recursive: true })
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
name: packageName,
exports: {
'./client': './lib/client.js',
'./package.json': './package.json',
},
dshClient: { platform: 'web' },
}))
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
*entries() {
for (const packageName of packageNames) {
yield { options: { name: packageName }, fiber: {}, disabled: false }
}
},
})
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
}
describe('client bundle activation', () => {
it('groups missing bundles under one source-build instruction with a package/path list', () => {
const firstName = '@fixture/missing-first'
const secondName = '@fixture/missing-second'
const firstPath = writePackage(firstName)
const secondPath = writePackage(secondName)
expect(() => construct([firstName, secondName])).toThrow([
'client-modules: 2 client packages failed to compose:',
' client bundles not found; run `pnpm run build` before launch:',
` - package: ${firstName}`,
` path: ${firstPath}`,
` - package: ${secondName}`,
` path: ${secondPath}`,
].join('\n'))
})
it('does not report other bundle read failures as missing builds', () => {
const packageName = '@fixture/unreadable-client'
const clientPath = writePackage(packageName)
mkdirSync(clientPath, { recursive: true })
let thrown: unknown
try {
construct([packageName])
} catch (error) {
thrown = error
}
expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
expect(String(thrown)).toContain(' other failures:')
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
})

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: 3a935a787df5f42fd5bcaa181844f400856cf240
README.zh.md: 1946c333857ed30f363c377428b44e2824b2298b
README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b

View File

@@ -12,6 +12,8 @@ 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. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.

View File

@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。

View File

@@ -8,8 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
@@ -22,6 +23,12 @@ export interface ISessions {
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* The `session.search` result bound the wire schema fixes, exposed to
* presentation as injected data. Not per-connection state: every transport
* (fixture included) reports the same number.
*/
readonly searchResultLimit: number
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
@@ -29,6 +36,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results, or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.

View File

@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {

View File

@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
*/
export type SessionListPhase = 'pending' | 'ready'
/** Request-local content hit returned to sidebar search consumers. */
export interface SessionSearchResultItem {
sessionId: SessionId
snippet: string
}
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
@@ -248,6 +257,24 @@ export class SessionManager {
return this.listInflight
}
/**
* Search visible session message content without adding transient query
* state to the list snapshot.
* @param query - non-blank literal phrase.
* @param signal - cancellation for superseded UI queries.
* @returns the Host result or a folded transport error.
*/
async search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
try {
return (await this.api.sessions.search({ query }, signal)).result
} catch (error: unknown) {
return transportError(error)
}
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition

View File

@@ -16,7 +16,12 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
* injected data. Not per-connection state: the `session.search` response
* schema caps `items` at this constant, so every transport (fixture included)
* reports the same number.
*/
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** 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. */
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
return this.manager.refreshList()
}
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import * as RuntimeClient from '../src/client/index.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
// The bound the wire schema enforces, not a per-connection negotiation.
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),

View File

@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
})
})
describe('search', () => {
it('returns bounded Host results and forwards the caller signal', async () => {
const api = new FakeApiClient()
api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
}))
const manager = new SessionManager(api)
const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
},
})
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
expect(api.lastSearchSignal).toBe(signal)
})
it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onSearch = () => Promise.resolve(err({
code: 'internal',
message: 'index unavailable',
details: {},
}))
const signal = new AbortController().signal
await expect(manager.search('first', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'index unavailable' },
})
api.onSearch = () => Promise.reject(new Error('wire down'))
await expect(manager.search('second', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'wire down' },
})
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()

View File

@@ -69,6 +69,29 @@ describe('list store projection', () => {
})
})
describe('search', () => {
it('delegates transient content search without changing the list snapshot', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const before = b.svc.list.getSnapshot()
b.api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
hasMore: false,
}))
const signal = new AbortController().signal
await expect(b.svc.search('needle', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
hasMore: false,
},
})
expect(b.api.lastSearchSignal).toBe(signal)
expect(b.svc.list.getSnapshot()).toBe(before)
})
})
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
@@ -37,6 +38,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
// The double reports the wire schema's own search bound, like the production
// service — a transport-varying limit would be a fiction no client can see.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +401,27 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* Replace the sidebar-search result page (the call is still recorded).
* @param impl - hits for a query, as the Host would rank them.
*/
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
this.searchStub = impl
}
/**
* Content search over the fixture corpus (recorded). The default answers an
* empty page: content ranking is Host behavior, so a scenario that asserts
* hits declares them through {@link TestSessions.stubSearch}.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search (recorded and forwarded).
* @returns the stubbed or empty result page.
*/
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
this.calls.push({ method: 'search', args: [query, signal] })
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).

View File

@@ -221,6 +221,28 @@ describe('sessions', () => {
])
await runtime.dispose()
})
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
const signal = new AbortController().signal
expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
await expect(runtime.sessions.search('marker', signal))
.resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
runtime.sessions.stubSearch(query => ({
items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
hasMore: true,
}))
await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
})
expect(runtime.sessions.calls).toEqual([
{ method: 'search', args: ['marker', signal] },
{ method: 'search', args: ['marker', signal] },
])
await runtime.dispose()
})
})
describe('stores', () => {

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../host/apiproxy"
}
]
}

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/ui-conversation/README.md
README.md: ee02c4fb006bfd18b64c0736c7324a09481aeed3
README.zh.md: c6b757fc1bdbe951cd38dccc97d37c96cbf3de7d
README.md: 88e0e0bf2edb6c91d2de5b767b9924d245d9560e
README.zh.md: c507bc88ae8d406d199a682001257bd4ca04b915

View File

@@ -26,7 +26,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -26,7 +26,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。`plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -52,6 +52,10 @@ const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
const ABSENT_MENU_LAUNCHER = {
getSnapshot: (): string | null => null,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
@@ -192,14 +196,28 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
}
}
const shell = inputHub.shell(sessionId)
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {
shell.dismissPopup()
const snapshot = shell.snapshot
slash.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
@@ -211,7 +229,11 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
hooks: {
notices: shell.notices,
lexicon: shell.lexicon,
menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER,
},
}
},
}, InputBar)

View File

@@ -5,7 +5,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -274,14 +274,14 @@ export interface ComposerBarOwnerProps {
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
@@ -301,6 +301,8 @@ export interface ComposerBarInjected {
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** Source name opened by the programmatic menu launcher, or null. */
menuLauncher: ObservableSnapshot<string | null>
}
}

View File

@@ -106,6 +106,17 @@ export class InputHub implements InputService {
return this.shell(id)
}
/**
* Resolve the optional slash controller for composer chrome that launches
* the shared candidate menu without typing a trigger.
* @param id - session id.
* @returns the resident controller, or undefined when ui-slash is absent.
*/
slash(id: SessionId): SlashController | undefined {
const actx = this.sessions().scope(id)
return actx === undefined ? undefined : this.controller(actx)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is

View File

@@ -19,7 +19,7 @@ export const zh = {
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.addAttachment': '添加附件',
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
@@ -108,7 +108,7 @@ export const en = {
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.addAttachment': 'Add attachment',
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',

View File

@@ -34,13 +34,14 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
@@ -257,7 +258,11 @@ export function InputBar({
inputRef.current?.focus()
}
const addText = addLabel ?? t('input.addAttachment')
const onToggleCommandMenu = (): void => {
const el = inputRef.current
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
@@ -399,11 +404,13 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addText}
title={addText}
disabled={locked}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onAdd}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>

View File

@@ -188,9 +188,11 @@ describe('conversation slot inject surface', () => {
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop!

View File

@@ -50,6 +50,8 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
@@ -77,6 +79,7 @@ function bench(over?: BenchOptions) {
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
@@ -100,8 +103,10 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: () => Promise.resolve(true),
// Mirrors the real lookup chain (conversation namespace, then common).
@@ -120,7 +125,7 @@ function bench(over?: BenchOptions) {
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
describe('Enter semantics', () => {
@@ -205,7 +210,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('会话不可用')
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
@@ -438,10 +443,10 @@ describe('strips and variants', () => {
})
})
describe('placeholder chrome and control seats', () => {
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('添加附件')).toBeTruthy()
expect(view.getByLabelText('命令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Both seats dispatched, nothing rendered.
@@ -450,6 +455,18 @@ describe('placeholder chrome and control seats', () => {
expect(view.queryByLabelText('Model')).toBeNull()
})
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
const toggleCommandMenu = vi.fn()
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
textarea.setSelectionRange(2, 7)
const launcher = view.getByLabelText('命令')
expect(launcher.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(launcher)
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
act(() => { menuLauncher.set('command') })
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('the Access chip renders the projection value and submits /permission on pick', async () => {
const permissions = {
options: [
@@ -489,10 +506,10 @@ describe('placeholder chrome and control seats', () => {
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
})
it('disabled locks the Access chip and attach control (running does not)', () => {
it('disabled locks the Access chip and command launcher (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })

View File

@@ -46,8 +46,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
@@ -175,7 +177,7 @@ describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea).disabled).toBe(true)
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})

View File

@@ -132,8 +132,18 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
controller.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(controller.launcher),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),

View File

@@ -152,8 +152,10 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
stop={stop}
command={() => Promise.resolve(true)}
t={t}

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/ui-plan/README.md
README.md: 568539c19331cc268217ee2c28b928c38a68323c
README.zh.md: 68e3092ad77267a779d21ba627ce2f19469ae05b
README.md: fcc4fbab4fbe1a8cc27119366b21ef55c669ba30
README.zh.md: b618199616e45f69d62f3507c96d367bb3b9909f

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster.
Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `conversation` locale namespace (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `conversation` locale namespace (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
@@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec
- **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies.
- **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip.
- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row.
- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row.

View File

@@ -4,7 +4,7 @@
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧node 侧是空 applyroster 行。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
plan mode `/plan` 命令进入UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chiphover 出现的 × `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host或尚无会话的 Draft不显示任何内容。plan mode 为有效目标期间composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染owner 提供的 placeholder 优先)。
plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan也可以输入 `/plan`而本包package不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮`command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host或尚无会话的 Draft不显示任何内容。plan mode 为有效目标期间composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染owner 提供的 placeholder 优先)。
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障以内联错误呈现chip 保持显示直至投影确认退出。
@@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m
- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。
- **无未激活态 plan 控件**——入口使用共享 Command source有能力但 mode 未激活的会话在工具行不显示 plan 入口。

View File

@@ -40,6 +40,7 @@
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
@@ -52,6 +53,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -1,5 +1,4 @@
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
business accent pair (same token pairing as the trajectory user badge). */
/* Active plan status follows Figma's warn-state pill. */
.wrap {
display: inline-flex;
@@ -10,30 +9,25 @@
.chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
gap: 4px;
min-width: 34px;
padding: 2px 8px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
border-radius: 999px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-label);
font-size: 13px;
font-weight: 500;
line-height: 20px;
cursor: pointer;
}
.chip:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
would otherwise swap it back to the neutral hover wash. */
.chip[aria-pressed='true'],
.chip[aria-pressed='true']:hover:not(:disabled) {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-state-warn-primary);
}
.chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary);
outline: 2px solid var(--dsw-alias-state-warn-label);
outline-offset: 2px;
}
@@ -42,6 +36,12 @@
cursor: default;
}
.close {
display: inline-flex;
align-items: center;
color: currentColor;
}
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and
// its {locked} owner share).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -11,16 +12,14 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected> & PropsLocale<'plan'>
/**
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
* whenever the capability is present and reflects the effective target as its
* pressed state (`pending ? !active : active` — a folded host value, not
* client optimism, so an arriving frame corrects it). Clicking executes
* /plan or /plan off toward the opposite target.
* Plan-mode status over the host-computed `plan` projection. The chip renders
* only while the effective target is plan mode (`pending ? !active : active`
* — a folded host value, not client optimism) and executes /plan off.
*/
export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProps) {
export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipProps) {
const plan = useProjection('plan')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const [leaving, setLeaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const aliveRef = useRef(true)
useEffect(() => {
@@ -30,26 +29,22 @@ export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProp
}
}, [])
// Absent capability (no plan-mode host plugin / no session yet): no seat
// content — without the capability there is nothing to toggle.
if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const toggle = (): void => {
// No busy/locked guard: both disable the button, so no click arrives.
// Failure copy stays English (error-surface policy: not localized).
const on = !target
const failText = on ? 'failed to enter plan mode' : 'failed to exit plan mode'
setBusy(true)
const off = (): void => {
// No leaving/locked guard: both disable the button, so no click arrives.
setLeaving(true)
setError(null)
void setPlanMode(on).then((failure) => {
void exitPlanMode().then((failure) => {
if (!aliveRef.current) return
setBusy(false)
setError(failure === null ? null : { text: failText, detail: failure })
setLeaving(false)
setError(failure)
}, (reason: unknown) => {
if (!aliveRef.current) return
setBusy(false)
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
setLeaving(false)
setError(reason instanceof Error ? reason.message : String(reason))
})
}
@@ -58,16 +53,19 @@ export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProp
<button
type="button"
className={css.chip}
aria-pressed={target}
aria-label={target ? t('chip.on.aria') : t('chip.off.aria')}
title={target ? t('chip.on.title') : t('chip.off.title')}
disabled={locked || busy}
onClick={toggle}
aria-label={t('chip.on.aria')}
title={t('chip.on.title')}
disabled={locked || leaving}
onClick={off}
>
{/* Design literal, not copy: the chip wordmark stays 'Plan on/off' in every locale. */}
Plan { target ? 'on' : 'off' }
{/* Design literal, not copy: the chip wordmark stays 'Plan' in every locale. */}
Plan
<span className={css.close} aria-hidden>
<IconCloseFill14 size={12} />
</span>
</button>
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
{/* Failure copy stays English (error-surface policy: not localized). */}
{error !== null && <span className={css.error} role="status" title={error}>failed to exit plan mode</span>}
</span>
)
}

View File

@@ -1,11 +1,11 @@
/**
* Plan control plugin, browser half: occupies the composer's named
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
* `plan` projection is present the chip renders in both states and executes
* /plan or /plan off through `command.execute` toward the opposite target;
* an absent projection (no capability) leaves the seat empty. Reads ride the
* generic projection pair through the standard-kit `useProjection` (an absent
* key is capability absence); zero client-side plan state.
* `conversation.input.plan` seat with an active-state status chip. Plan mode
* is entered through the command source; while the projection's effective
* target is plan mode the chip renders and executes /plan off through
* `command.execute`, otherwise the seat stays empty. Reads ride the generic
* projection pair through the standard-kit `useProjection`; zero client-side
* plan state.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -33,11 +33,10 @@ const NS = 'plan'
/** Injected business face of the composer plan seat. */
export interface PlanChipInjected {
/**
* Switch plan mode by executing /plan (on) or /plan off.
* @param on - desired target: true enters plan mode, false leaves it.
* Leave plan mode by executing /plan off.
* @returns null on admitted execution; a user-visible failure line otherwise.
*/
setPlanMode: (on: boolean) => Promise<string | null>
exitPlanMode: () => Promise<string | null>
}
/**
@@ -59,12 +58,11 @@ export function apply(ctx: ClientContext): void {
locale: NS,
inject: (sessionId: SessionId): PlanChipInjected => ({
// Failure strings stay English (error-surface policy: not localized).
setPlanMode: async (on) => {
const line = on ? '/plan' : '/plan off'
exitPlanMode: async () => {
const connection = ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId, line })
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
if (!result.ok) return `${result.error.message} (${result.error.code})`
if (!result.value.matched) return `unknown command: ${line}`
if (!result.value.matched) return 'unknown command: /plan off'
return null
},
}),

View File

@@ -1,9 +1,9 @@
/**
* ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan
* toggle chip; the injected face executes /plan or /plan off by direction and
* folds admission outcomes into null (admitted) or a user-visible failure
* line; teardown empties the seat (HMR safety).
* conversation-declared `conversation.input.plan` single seat with the active
* plan status chip; the injected face executes /plan off and folds admission
* outcomes into null (admitted) or a user-visible failure line; teardown
* empties the seat (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -52,7 +52,7 @@ describe('ui-plan browser apply', () => {
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
})
it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -60,22 +60,20 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.setPlanMode(false)).resolves.toBeNull()
await expect(injected.exitPlanMode()).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never)
await expect(injected.setPlanMode(false)).resolves.toBe('gone (session-not-found)')
await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)')
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } },
} as never)
await expect(injected.setPlanMode(true)).resolves.toBe('unknown command: /plan')
await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)

View File

@@ -1,11 +1,9 @@
// @vitest-environment jsdom
/**
* PlanChip over the `plan` projection: nothing renders while the capability
* is absent; with the capability present the chip renders in both states with
* aria-pressed following the effective target (pending folds — /plan shows
* pressed immediately, /plan off unpressed immediately); clicking executes
* the command toward the opposite target and surfaces direction-specific
* failures while the projection still owns the displayed state.
* is absent or the effective target is the default mode; while plan mode is
* the target, the chip executes /plan off and remains visible through failures
* until the projection confirms the exit.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -24,98 +22,74 @@ const t: PlanChipProps['t'] = makeTranslate(zh, commonZh)
function setup(
plan: PlanProjection | undefined,
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
locked = false,
) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
const props = { useProjection, locked, setPlanMode, t } as unknown as PlanChipProps
const props = { useProjection, locked, exitPlanMode, t } as unknown as PlanChipProps
const view = render(<PlanChip {...props} />)
return { store, setPlanMode, view }
return { store, exitPlanMode, view }
}
const onChip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' })
const offChip = () => screen.getByRole('button', { name: 'plan mode 已关闭,按下开启' })
const chip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' })
describe('PlanChip', () => {
it('renders nothing while the capability is absent', () => {
it('renders nothing for an absent capability or a default-mode target', () => {
const absent = setup(undefined)
expect(absent.view.container.innerHTML).toBe('')
cleanup()
const inactive = setup({ active: false, pending: false })
expect(inactive.view.container.innerHTML).toBe('')
cleanup()
const leaving = setup({ active: true, pending: true })
expect(leaving.view.container.innerHTML).toBe('')
})
it('reflects the effective target as the pressed state, folding pending', () => {
setup({ active: false, pending: false })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
cleanup()
it('renders the Plan status for active and pending-entry targets', () => {
setup({ active: true, pending: false })
expect(onChip().getAttribute('aria-pressed')).toBe('true')
expect(chip().textContent).toBe('Plan')
cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true })
expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// Active with a pending exit: the target is default — already unpressed.
setup({ active: true, pending: true })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
expect(chip().textContent).toBe('Plan')
})
it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
it('executes /plan off once and follows the projection down', async () => {
let resolve!: (value: string | null) => void
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false, pending: false }, setPlanMode)
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
expect(setPlanMode).toHaveBeenLastCalledWith(true)
// Busy while its own call is in flight.
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
resolve(null)
// The command's run record folds: target flips, the chip presses.
store.set({ value: { active: false, pending: true } })
await waitFor(() => {
expect(onChip().getAttribute('aria-pressed')).toBe('true')
})
})
it('pressed chip executes /plan off and follows the projection down', async () => {
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
const { store } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
expect(setPlanMode).toHaveBeenLastCalledWith(false)
store.set({ value: { active: true, pending: true } })
await waitFor(() => {
expect(offChip().getAttribute('aria-pressed')).toBe('false')
expect(screen.queryByRole('button', { name: 'plan mode 已开启,按下关闭' })).toBeNull()
})
})
it('disables under the locked owner prop', () => {
setup({ active: true, pending: false }, vi.fn(), true)
expect((onChip() as HTMLButtonElement).disabled).toBe(true)
expect((chip() as HTMLButtonElement).disabled).toBe(true)
})
it('surfaces direction-specific admission and transport failures while staying visible', async () => {
const exitFailing = vi.fn()
it('surfaces admission and transport failures while staying visible', async () => {
const exitPlanMode = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitFailing)
fireEvent.click(onChip())
setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no')
expect(onChip()).toBeTruthy()
expect(chip()).toBeTruthy()
fireEvent.click(onChip())
fireEvent.click(chip())
expect(await screen.findByTitle('network down')).toBeTruthy()
fireEvent.click(onChip())
fireEvent.click(chip())
expect(await screen.findByTitle('socket closed')).toBeTruthy()
cleanup()
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
setup({ active: false, pending: false }, enterFailing)
fireEvent.click(offChip())
expect((await screen.findByText('failed to enter plan mode')).getAttribute('title')).toBe('agent busy')
expect(offChip()).toBeTruthy()
})
it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -124,14 +98,14 @@ describe('PlanChip', () => {
{ active: true, pending: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
)
fireEvent.click(onChip())
fireEvent.click(chip())
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})

View File

@@ -23,6 +23,9 @@
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},

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/ui-slash/README.md
README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38
README.zh.md: 03dac56870de5b083124716825001009b4293736
README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2
README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
输入触发流水线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent智能体支撑因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
输入触发流水线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent智能体支撑因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`T2是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0同值保持注册序组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source程序化 launcher 只 seed 所请求的 source并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0同值保持注册序组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
`/client` 导出表层是插件主体(`apply``inject`)、`SlashService``MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。

View File

@@ -40,6 +40,12 @@ export interface SlashControllerDeps {
export class SlashController {
/** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Name of the source opened through the programmatic launcher, or null for
* trigger-detected/closed menus. Composer chrome subscribes to this store
* for the launcher's expanded state without owning a second menu model.
*/
readonly launcher: SnapshotStore<string | null> = createSnapshotStore<string | null>(null)
/**
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
* sources implementing the lexicon hook are polled with the session
@@ -81,6 +87,8 @@ export class SlashController {
*/
track(draft: string, caret: number, guard: TriggerGuard, draftRev: number): void {
if (this.disposed) return
const launched = this.launcher.getSnapshot() !== null
this.clearLauncher()
const raw = detectTrigger(draft, caret, guard)
if (raw === null) {
this.hit = null
@@ -90,7 +98,7 @@ export class SlashController {
}
const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } }
const prev = this.menu.getSnapshot()
const same = prev.open && prev.hit !== null
const same = !launched && prev.open && prev.hit !== null
&& prev.hit.trigger === hit.trigger && prev.hit.query === hit.query
&& prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end
this.hit = hit
@@ -101,13 +109,40 @@ export class SlashController {
this.reduce({ type: 'close' })
return
}
if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) {
if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) {
this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name)))
}
this.reduce({ type: 'hit', hit })
this.fetchCandidates(hit, roster)
}
/**
* Toggle a menu containing exactly one registered source. The supplied hit
* is a synthetic selection span rather than a typed trigger token, but
* picks deliberately reuse the ordinary source callback and scoped input
* mutation pipeline.
* @param source - registered source name under `hit.trigger`.
* @param hit - synthetic hit carrying position and pick-time draft CAS.
*/
toggleSource(source: string, hit: TriggerHit): void {
if (this.disposed) return
if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) {
this.dismiss()
return
}
const match = this.deps.roster.sources(hit.trigger).find(item => item.name === source)
if (match === undefined) {
this.dismiss()
return
}
this.stopFetch()
this.hit = hit
this.launcher.set(source)
this.menu.set(seedGroups(this.menu.getSnapshot(), [source]))
this.reduce({ type: 'hit', hit })
this.fetchCandidates(hit, [match])
}
/**
* Pointer pick from MenuView: route the clicked candidate through onPick
* and execute claim/insert outcomes via the scoped input events.
@@ -349,9 +384,14 @@ export class SlashController {
this.fetch = null
}
private clearLauncher(): void {
if (this.launcher.getSnapshot() !== null) this.launcher.set(null)
}
private reduce(ev: MenuEvent): void {
const cur = this.menu.getSnapshot()
const next = menuReduce(cur, ev)
if (next !== cur) this.menu.set(next)
if (!next.open) this.clearLauncher()
}
}

View File

@@ -351,6 +351,57 @@ describe('track', () => {
})
})
describe('programmatic source launcher', () => {
it('opens only the requested source and reuses its ordinary pick span', async () => {
const command = readySource('/', 'command', [{ name: 'goal' }])
const skill = readySource('/', 'skill', [{ name: 'review' }])
const { controller } = controllerBench([command.source, skill.source])
const hit = {
trigger: '/' as const,
query: '',
position: 'leading' as const,
span: { start: 2, end: 5, draftRev: 7 },
}
controller.toggleSource('command', hit)
await tick()
expect(controller.launcher.getSnapshot()).toBe('command')
expect(controller.menu.getSnapshot()).toMatchObject({
open: true,
hit,
groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
})
controller.pick('command', 0)
expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
expect(skill.picks).toHaveLength(0)
expect(controller.launcher.getSnapshot()).toBeNull()
})
it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
const command = readySource('/', 'command', [{ name: 'goal' }])
const skill = readySource('/', 'skill', [{ name: 'review' }])
const { controller } = controllerBench([command.source, skill.source])
const hit = {
trigger: '/' as const,
query: '',
position: 'leading' as const,
span: { start: 0, end: 0, draftRev: 1 },
}
controller.toggleSource('command', hit)
controller.toggleSource('command', hit)
expect(controller.menu.getSnapshot().open).toBe(false)
expect(controller.launcher.getSnapshot()).toBeNull()
controller.toggleSource('command', hit)
controller.track('/g', 2, { tier: 'plain' }, 2)
await tick()
expect(controller.launcher.getSnapshot()).toBeNull()
expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
})
})
describe('scope-birth warm', () => {
it('construction warms every source once with the session projection', () => {
const cmd = deferredSource('/', 'command')

View File

@@ -44,9 +44,7 @@ afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
beforeEach(() => {
// Node 22+ exposes an experimental localStorage global that is undefined
// without --localstorage-file; only clear when a real Storage is present.
if (typeof localStorage !== 'undefined') localStorage.clear()
localStorage.clear()
})
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */

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/ui-workspace/README.md
README.md: 860c24b8a25a1e9968261f586c16163579131a1c
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6
README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
@@ -20,5 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot两个表层使用同一套 Workspace 菜单和创建流程。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair并保留现有的防抖与取消行为。每次新查询都会中止前一个请求内容搜索失败时元数据匹配项仍会显示同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
@@ -20,5 +22,6 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork
## 已知限制与暂缓事项
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token短语匹配选择结果会打开 Session而不是匹配的事件。
- **没有 Session 删除控件**Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -217,6 +217,26 @@
scrollbar-gutter: stable;
}
.list > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchStatus,
.searchWarning {
padding: 10px 12px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.searchWarning {
color: var(--dsw-alias-label-secondary);
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded

View File

@@ -13,11 +13,13 @@ import {
Button, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode } from './tree.ts'
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
@@ -26,6 +28,21 @@ import css from './WorkspaceBrowser.module.css'
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300
/** Pause between the latest keystroke and a Host content-search request. */
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
const withoutNul = value.replaceAll('\0', '')
if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul
let end = SEARCH_QUERY_MAX_CODE_UNITS
const last = withoutNul.charCodeAt(end - 1)
const next = withoutNul.charCodeAt(end)
if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end--
return withoutNul.slice(0, end)
}
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
@@ -85,8 +102,6 @@ type SessionTreeProps = Pick<
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
query: string
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
@@ -97,7 +112,7 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces, query,
useSessions, startSession, open, forkSession, workspaces,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
@@ -114,8 +129,8 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, query }),
[list, workspaces, expandedProjects, query],
() => deriveGroups(list, workspaces, { expandedProjects }),
[list, workspaces, expandedProjects],
)
const now = Date.now()
@@ -123,7 +138,7 @@ function SessionTree({
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{groups.map(group => (
// Group section: header row + expanded top-level session rows. The
@@ -151,10 +166,10 @@ function SessionTree({
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace session rows outside search. The drag
// Draggable: real-workspace session rows. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined && query === ''
const draggable = group.workspaceId !== undefined
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => {
@@ -208,15 +223,15 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query' | 't'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const rows = useMemo(() => deriveFlat(list), [list])
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{rows.map(node => (
<SessionNodeItem
@@ -236,6 +251,74 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }:
)
}
interface RemoteSearchState {
query: string
status: 'idle' | 'loading' | 'ready' | 'error'
items: readonly SessionSearchResultItem[]
hasMore: boolean
}
/** Flat search body: local metadata matches plus the current Host result page. */
function SearchResults({
useSessions,
open,
workspaces,
query,
remote,
resultLimit,
t,
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
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, 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}>
<div className={css.searchTree} role="tree" aria-label={t('search.results.aria')}>
{results.items.map(result => (
<SearchResultItem
key={result.id}
result={result}
currentId={list.current}
onOpen={open}
/>
))}
</div>
{pending && (
<div className={css.searchStatus} role="status">{t('search.pending')}</div>
)}
{failed && (
<div className={css.searchWarning} role="status">
{t('search.unavailable')}
</div>
)}
{!pending && results.items.length === 0 && (
<div className={css.empty}>{t('search.noMatches')}</div>
)}
{results.hasMore && (
<div className={css.searchStatus}>
{t('search.hasMore', { n: resultLimit })}
</div>
)}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the browsing region.
* @param props - composed slot props (shell owner share + store + injected actions).
@@ -256,6 +339,8 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
searchSessions,
searchResultLimit,
useDirectoryFlow,
renderSlot,
t,
@@ -265,6 +350,13 @@ export function WorkspaceBrowser({
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const normalizedQuery = sanitizeSearchQuery(query).trim()
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
query: '',
status: 'idle',
items: [],
hasMore: false,
})
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the picker menu (same popover in wide and rail
// states; the menu anchors on this button).
@@ -285,6 +377,43 @@ export function WorkspaceBrowser({
}
}, [wide, searchOnExpand])
useEffect(() => {
if (normalizedQuery === '') {
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
return
}
const controller = new AbortController()
setRemoteSearch({
query: normalizedQuery,
status: 'loading',
items: [],
hasMore: false,
})
const timer = window.setTimeout(() => {
searchSessions(normalizedQuery, controller.signal).then((result) => {
if (controller.signal.aborted) return
setRemoteSearch({
query: normalizedQuery,
status: 'ready',
items: result.items,
hasMore: result.hasMore,
})
}).catch(() => {
if (controller.signal.aborted) return
setRemoteSearch({
query: normalizedQuery,
status: 'error',
items: [],
hasMore: false,
})
})
}, SEARCH_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [normalizedQuery, searchSessions])
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
const [renameDraft, setRenameDraft] = useState('')
@@ -442,8 +571,9 @@ export function WorkspaceBrowser({
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder={t('search.placeholder')}
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
onChange={(e) => { setQuery(e.target.value) }}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
/>
)}
{wide && query !== '' && (
@@ -461,35 +591,46 @@ export function WorkspaceBrowser({
{/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
{wide && (normalizedQuery !== ''
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} query={query} t={t}
<SearchResults
useSessions={useSessions}
open={open}
workspaces={workspaces}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
forkSession={forkSession}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
: groupBy === 'flat'
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
forkSession={forkSession}
workspaces={workspaces}
startSession={startSession}
open={open}
insertSessionBefore={insertSessionBefore}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
<Modal

View File

@@ -24,7 +24,9 @@ import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, Props
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -93,6 +95,16 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
startSession: (workspaceId?: WorkspaceId) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/**
* Search current visible conversation messages. The Host fixes the result
* bound; `hasMore` means the query needs narrowing.
*/
searchSessions: (
query: string,
signal: AbortSignal,
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
/** Maximum number of merged rows rendered for one search. */
searchResultLimit: number
/** Rename a Session (explicit user title; resolves on host acceptance). */
renameSession: (sessionId: SessionId, title: string) => Promise<void>
/** Fork a Session at its last completed turn and open the child. */

View File

@@ -54,6 +54,12 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
const result = await ctx.sessions.search(query, signal)
if (!result.ok) throw new Error(result.error.message)
return result.value
}
// Stable per-surface occupancy sources (the renderer's hook cache keys by
// source identity): true while the surface's directory-flow hole is filled.
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
@@ -67,6 +73,8 @@ export function apply(ctx: ClientContext): void {
// the runtime's shared action (recent-Workspace projection inside).
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
searchSessions,
searchResultLimit: ctx.sessions.searchResultLimit,
renameSession: async (sessionId, title) => {
// Row → session-face hop: rename is a per-session verb (ISession), not
// a list-service verb; the binding resolves any listed session.

View File

@@ -19,6 +19,11 @@ export const zh = {
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.clear': '清除搜索',
'search.results.aria': '搜索结果',
'search.pending': '正在搜索会话历史…',
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
'search.noMatches': '无匹配会话',
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.openFolder': '打开本地文件夹…',
'menu.createWorkspace': '新建工作区',
'picker.loading': '正在加载工作区…',
@@ -77,6 +82,11 @@ export const en = {
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.clear': 'Clear search',
'search.results.aria': 'Search results',
'search.pending': 'Searching session history…',
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
'search.noMatches': 'No matching sessions',
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.openFolder': 'Open local folder…',
'menu.createWorkspace': 'Create a new workspace',
'picker.loading': 'Loading workspaces…',

View File

@@ -24,6 +24,64 @@
background: var(--dsw-alias-interactive-bg-active);
}
.searchResultRow {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 62px;
box-sizing: border-box;
border: none;
border-radius: 8px;
padding: 7px 8px;
background: transparent;
cursor: pointer;
text-align: left;
color: var(--dsw-alias-label-primary);
}
.searchResultRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchResultRow.selected {
background: var(--dsw-alias-interactive-bg-active);
}
.searchResultHeading {
display: flex;
align-items: center;
min-width: 0;
}
.searchResultTitle {
min-width: 0;
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.searchResultWorkspace,
.searchResultSnippet {
margin-left: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 17px;
}
.searchResultWorkspace {
color: var(--dsw-alias-label-tertiary);
}
.searchResultSnippet {
color: var(--dsw-alias-label-secondary);
}
/* Two-line row: the leading slot (folder/chevron), title, and trailing
actions all top-align on the 20px first text line (figma cell) — content
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */

View File

@@ -13,7 +13,7 @@ import {
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SessionNode } from '../tree.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import { relativeTime } from '../tree.ts'
import css from './Rows.module.css'
@@ -202,6 +202,41 @@ export interface RowDragProps {
end: () => void
}
/**
* One flat search result: title, Workspace context, and optional content
* excerpt. Search navigation opens the session only; it does not address an
* event inside the conversation.
* @param props.result - merged local/content search row.
* @param props.currentId - selected session id.
* @param props.onOpen - open the selected session.
* @returns the result button.
*/
export function SearchResultItem({ result, currentId, onOpen }: {
result: SearchResultNode
currentId: string | undefined
onOpen: (id: SearchResultNode['id']) => void
}) {
const selected = result.id === currentId
return (
<button
type="button"
className={clsx(css.searchResultRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
onClick={() => { onOpen(result.id) }}
>
<span className={css.searchResultHeading}>
<span className={css.slot}>{result.running && <StateDot state="ongoing" />}</span>
<span className={css.searchResultTitle}>{result.title}</span>
</span>
<span className={css.searchResultWorkspace}>{result.workspace}</span>
{result.snippet !== undefined && (
<span className={css.searchResultSnippet}>{result.snippet}</span>
)}
</button>
)
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()

View File

@@ -3,7 +3,9 @@
* Unassigned Sessions trail under Ungrouped; only the selected blank Session
* remains visible.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
@@ -41,10 +43,24 @@ export interface GroupNode {
sessions: readonly SessionNode[]
}
/** One flat search row combining list metadata with an optional content match. */
export interface SearchResultNode {
id: SessionId
title: string
workspace: string
running: boolean
snippet?: string
}
/** Bounded merged search projection plus the refine-query hint bit. */
export interface SearchResultSet {
items: readonly SearchResultNode[]
hasMore: boolean
}
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
query: string
}
interface Group {
@@ -150,16 +166,13 @@ function sessionNode(s: SessionSummary): SessionNode {
/**
* Derive the workspace browser groups with every session as a top-level row.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* preserving Host account order. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matching sessions are forced visible, groups without a display-title or
* label hit are dropped, and a label-only hit
* keeps the bare group header. Non-current blank sessions are excluded
* everywhere; blank placeholders never match a search query.
* Every group shows; sessions populate under expanded groups, preserving
* Host account order. Blank sessions are excluded except for the selected
* provisional New Session row. Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
export function deriveGroups(
@@ -167,7 +180,6 @@ export function deriveGroups(
workspaces: readonly WorkspaceView[],
view: TreeView,
): GroupNode[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const currentGroup = list.current === undefined
? undefined
@@ -175,34 +187,18 @@ export function deriveGroups(
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
if (q === '') {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
} else {
const matches = g.sessions.filter(session => !session.blank && sessionTitle(session).toLowerCase().includes(q))
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.sessions.length,
expanded: matches.length > 0,
containsCurrent: g.key === currentGroup,
sessions: matches.map(sessionNode),
})
}
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
}
return groups
}
@@ -210,19 +206,16 @@ export function deriveGroups(
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency. Search mode filters by case-insensitive
* display-title substring.
* no parent/child adjacency. Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
const q = view.query.trim().toLowerCase()
export function deriveFlat(list: SessionListState): SessionNode[] {
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined || !sessionVisible(s, list.current)) continue
if (q !== '' && (s.blank || !sessionTitle(s).toLowerCase().includes(q))) continue
rows.push(s)
}
rows.sort(byRecency)
@@ -238,6 +231,83 @@ export interface RelativeTime {
n: number
}
/**
* Merge immediate title/Workspace substring matches with ranked Host content
* matches. Local rows lead newest-first, content-only rows retain backend
* order, and duplicate sessions receive the backend snippet in place.
* @param list - session metadata authority.
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param content - ranked Host content-search page.
* @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 }
const workspaceBySession = new Map<SessionId, string>()
for (const workspace of workspaces) {
for (const sessionId of workspace.sessionIds) {
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
}
}
const labelOf = (summary: SessionSummary): string =>
workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd)
const contentBySession = new Map<SessionId, SessionSearchResultItem>()
for (const item of content.items) {
if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item)
}
const local: SessionSummary[] = []
for (const id of list.ids) {
const summary = list.byId[id]
// Blank placeholders never match a query (their canonical title displays
// localized, so matching it would tie search to one language).
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
if (
sessionTitle(summary).toLowerCase().includes(q)
|| labelOf(summary).toLowerCase().includes(q)
) {
local.push(summary)
}
}
local.sort(byRecency)
const ordered: SessionSummary[] = []
const included = new Set<SessionId>()
const include = (summary: SessionSummary): void => {
if (included.has(summary.id)) return
included.add(summary.id)
ordered.push(summary)
}
for (const summary of local) include(summary)
for (const item of content.items) {
const summary = list.byId[item.sessionId]
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
}
return {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
return {
id: summary.id,
title: sessionTitle(summary),
workspace: labelOf(summary),
running: summary.running,
...match === undefined ? {} : { snippet: match.snippet },
}
}),
hasMore: content.hasMore || ordered.length > limit,
}
}
/**
* Compact relative time for session rows, as a structured bucket the
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).

View File

@@ -20,18 +20,22 @@ async function bench() {
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
const search = vi.fn(async () => ({
ok: true as const,
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
}))
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const fork = vi.fn(async () => 'forked' as never)
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, binding, fork } as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
insertSessionBefore, open, clear, renameSession, binding, fork,
insertSessionBefore, open, clear, search, renameSession, binding, fork,
}
}
@@ -79,6 +83,13 @@ describe('ui-workspace apply', () => {
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
const signal = new AbortController().signal
await expect(browser.searchSessions('match', signal)).resolves.toEqual({
items: [{ sessionId: 'session', snippet: 'match' }],
hasMore: false,
})
expect(b.search).toHaveBeenCalledWith('match', signal)
expect(browser.searchResultLimit).toBe(20)
await browser.renameSession('session' as never, 'renamed session')
expect(b.binding).toHaveBeenCalledWith('session')
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
@@ -124,6 +135,19 @@ describe('ui-workspace apply', () => {
unsubscribe()
})
it('rejects the browser search callback on a runtime business error', async () => {
const b = await bench()
b.search.mockImplementationOnce(async () => ({
ok: false,
error: { code: 'internal', message: 'index unavailable', details: {} },
}) as never)
declare(b.slots, 'sidebar.workspaces')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
await expect(browser.searchSessions('needle', new AbortController().signal))
.rejects.toThrow('index unavailable')
})
it('unregisters every entry on teardown', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace')

View File

@@ -5,8 +5,8 @@ import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/cli
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -44,6 +44,25 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
}
describe('workspace browser rows', () => {
it('renders a selected content-search row and opens only its session', () => {
const onOpen = vi.fn()
const result: SearchResultNode = {
id: sid('result'),
title: 'Result title',
workspace: 'Workspace context',
running: true,
snippet: 'matching message excerpt',
}
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('Workspace context')).toBeTruthy()
expect(screen.getByText('matching message excerpt')).toBeTruthy()
expect(row.hasAttribute('draggable')).toBe(false)
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(result.id)
})
it('renders an active Workspace and keeps its create action separate from toggling', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()

View File

@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveFlat, deriveGroups, projectLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import {
deriveFlat, deriveGroups, deriveSearchResults, projectLabel, relativeTime,
UNGROUPED_KEY, UNGROUPED_LABEL,
} from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
const sid = (id: string) => id as SessionId
@@ -16,12 +19,12 @@ const list = (...items: SessionSummary[]): SessionListState => ({
current: undefined,
phase: 'ready',
})
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title: id,
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], query = '') => ({
expandedProjects, query,
const view = (expandedProjects: readonly string[] = []) => ({
expandedProjects,
})
describe('deriveGroups', () => {
@@ -64,25 +67,6 @@ describe('deriveGroups', () => {
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
it('excludes blank sessions from search regardless of the query', () => {
const currentBlank = { ...summary('opaque-current', 5), blank: true }
const staleBlank = { ...summary('new session stale', 4), blank: true }
const real = { ...summary('real', 3), displayTitle: 'new session notes' }
const sessions = {
...list(currentBlank, staleBlank, real),
current: currentBlank.id,
}
const groups = deriveGroups(
sessions,
[workspace('first', ['opaque-current', 'new session stale', 'real'])],
view([], 'new session'),
)
// Only the real title hit matches; the current blank's placeholder title
// never participates (it displays localized, so matching it would tie
// search to one language).
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id])
})
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
@@ -96,7 +80,7 @@ describe('deriveGroups', () => {
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
{ expandedProjects: [UNGROUPED_KEY], query: '' },
{ expandedProjects: [UNGROUPED_KEY] },
)
expect(groups).toHaveLength(1)
@@ -120,31 +104,6 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
it('searches rows independently of lineage and keeps label-only hits', () => {
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
match.id, self.id, orphan.id, cycleA.id, cycleB.id,
])
const labelOnly = deriveGroups(
list(summary('hidden', 1)),
[workspace('label-hit', ['hidden']), workspace('other', [])],
view([], 'label'),
)
expect(labelOnly).toEqual([
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
])
})
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
const owned = summary('owned', 1)
const loose = summary('loose', 2)
@@ -162,19 +121,13 @@ describe('deriveFlat', () => {
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
const rows = deriveFlat(list(parent, child, tieB, tieA))
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
})
it('search filters by case-insensitive display-title substring', () => {
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
})
it('shows only the current blank session and excludes blanks from search', () => {
@@ -184,14 +137,120 @@ describe('deriveFlat', () => {
...list(summary('real', 1), currentBlank, staleBlank),
current: currentBlank.id,
}
const rows = deriveFlat(sessions, { query: '' })
const rows = deriveFlat(sessions)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
expect(rows.map(row => row.blank)).toEqual([true, false])
// Blank rows never match a query — not their placeholder title, not their id.
expect(deriveFlat(sessions, { query: 'new session' })).toEqual([])
expect(deriveFlat(sessions, { query: 'current-blank' })).toEqual([])
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
})
})
describe('deriveSearchResults', () => {
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
const titleHit = summary('title-hit', 30, '/projects/a')
titleHit.displayTitle = 'Needle title'
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
workspaceHit.displayTitle = 'Ordinary title'
const contentHit = summary('content-hit', 10, '/projects/c')
const sessions = list(titleHit, workspaceHit, contentHit)
const result = deriveSearchResults(
sessions,
[
workspace('a', ['title-hit'], 'Alpha'),
workspace('b', ['workspace-hit'], 'Needle Workspace'),
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
],
' NEEDLE ',
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
{ sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
],
hasMore: false,
},
10,
)
expect(result).toEqual({
items: [
{
id: titleHit.id,
title: 'Needle title',
workspace: 'Alpha',
running: false,
snippet: 'title session body excerpt',
},
{
id: workspaceHit.id,
title: 'Ordinary title',
workspace: 'Needle Workspace',
running: false,
},
{
id: contentHit.id,
title: 'content-hit',
workspace: 'c',
running: false,
snippet: 'body needle excerpt',
},
],
hasMore: false,
})
})
it('excludes blank sessions from search regardless of query or content hits', () => {
const currentBlank = { ...summary('opaque-current', 5), blank: true }
const staleBlank = { ...summary('new session stale', 4), blank: true }
const sessions = {
...list(currentBlank, staleBlank),
current: currentBlank.id,
}
// Blank placeholders never match — not their localized-display title, not
// their id, and not even a backend content hit naming them.
const result = deriveSearchResults(
sessions,
[workspace('first', ['opaque-current', 'new session stale'])],
'new session',
{
items: [
{ sessionId: staleBlank.id, snippet: 'stale body' },
{ sessionId: currentBlank.id, snippet: 'current body' },
],
hasMore: false,
},
10,
)
expect(result.items).toEqual([])
})
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 },
3,
)
expect(overflow.items).toHaveLength(3)
expect(overflow.hasMore).toBe(true)
const backendMore = deriveSearchResults(
list(summary('body', 1)),
[],
'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 }, 3))
.toEqual({ items: [], hasMore: false })
})
})

View File

@@ -62,6 +62,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
actions: store.actions,
startSession: vi.fn(),
open: vi.fn(),
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
searchResultLimit: 20,
renameSession: vi.fn(async () => {}),
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
@@ -219,38 +221,215 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('新会话')).toBeNull()
})
it('searches across groups, clears via the clear button, and shows the empty states', () => {
const sessions = sessionState([
summary('needle-row', 2, { displayTitle: 'Needle row' }),
summary('other-row', 1, { displayTitle: 'Other row' }),
])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'needle' } })
// Search forces matches visible without expansion state.
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByText('无匹配结果')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(input.value).toBe('')
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
expect(document.activeElement).toBe(input)
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
vi.useFakeTimers()
try {
const sessions = sessionState([
summary('needle-row', 2, { displayTitle: 'Needle row' }),
summary('other-row', 1, { displayTitle: 'Other row' }),
])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'needle' } })
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
const status = screen.getByRole('status')
expect(status.textContent).toBe('正在搜索会话历史…')
expect(resultTree.contains(status)).toBe(false)
fireEvent.change(input, { target: { value: 'zzz' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('无匹配会话')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(input.value).toBe('')
expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('shows the no-sessions empty state in both modes', () => {
const b = mount()
expect(screen.getByText('暂无会话')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('暂无会话')).toBeTruthy()
// Flat search misses show No matches.
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
expect(screen.getByText('无匹配结果')).toBeTruthy()
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
vi.useFakeTimers()
try {
const open = vi.fn()
const searchSessions = vi.fn(async () => ({
items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
hasMore: true,
}))
mount({
useSessions: hook(sessionState([
summary('body-hit', 1, { displayTitle: 'Research notes' }),
])),
useWorkspaces: hook(workspaceState([
workspace('research', ['body-hit'], 'Research Workspace'),
])),
open,
searchSessions,
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'waterfall token' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
expect(screen.queryByText('Research notes')).toBeNull()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
expect(screen.getByText('Research notes')).toBeTruthy()
expect(screen.getByText('Research Workspace')).toBeTruthy()
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy()
fireEvent.click(screen.getByRole('treeitem'))
expect(open).toHaveBeenCalledWith(sid('body-hit'))
expect(input.value).toBe('waterfall token')
} finally {
vi.useRealTimers()
}
})
it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
vi.useFakeTimers()
try {
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
expect(input.maxLength).toBe(500)
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
expect(input.value).toBe('y'.repeat(500))
const expected = `prefix${'x'.repeat(493)}`
fireEvent.change(input, {
target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
})
expect(input.value).toBe(expected)
expect(input.value.length).toBe(499)
expect(input.value).not.toContain('\0')
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
} finally {
vi.useRealTimers()
}
})
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
vi.useFakeTimers()
try {
const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
mount({
useSessions: hook(sessionState([
summary('local-hit', 1, { displayTitle: 'Needle title' }),
])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
searchSessions,
})
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), {
target: { value: 'needle' },
})
expect(screen.getByText('Needle title')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('Needle title')).toBeTruthy()
expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy()
expect(screen.queryByText('无匹配会话')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('aborts a superseded request and ignores its stale result', async () => {
vi.useFakeTimers()
try {
let resolveFirst!: (value: {
items: { sessionId: SessionId; snippet: string }[]
hasMore: boolean
}) => void
const first = new Promise<{
items: { sessionId: SessionId; snippet: string }[]
hasMore: boolean
}>((resolve) => { resolveFirst = resolve })
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
? first
: Promise.resolve({
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
hasMore: false,
}))
mount({
useSessions: hook(sessionState([
summary('first-hit', 2, { displayTitle: 'Old result' }),
summary('second-hit', 1, { displayTitle: 'Fresh result' }),
])),
searchSessions,
})
const input = screen.getByPlaceholderText('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
expect(firstSignal.aborted).toBe(false)
fireEvent.change(input, { target: { value: 'second' } })
expect(firstSignal.aborted).toBe(true)
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('Fresh result')).toBeTruthy()
await act(async () => {
resolveFirst({
items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
hasMore: false,
})
await Promise.resolve()
})
expect(screen.queryByText('Old result')).toBeNull()
expect(screen.getByText('Fresh result')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('ignores a rejected request after it has been superseded', async () => {
vi.useFakeTimers()
try {
let rejectFirst!: (reason: Error) => void
const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
const searchSessions = vi.fn((query: string) => query === 'first'
? first
: Promise.resolve({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
fireEvent.change(input, { target: { value: 'second' } })
await act(async () => {
rejectFirst(new Error('stale failure'))
await Promise.resolve()
})
expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
vi.useFakeTimers()
try {
const b = mount()
expect(screen.getByText('暂无会话')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('暂无会话')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('无匹配会话')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('rail state renders icon controls that request expansion', () => {
@@ -551,6 +730,6 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.getAttribute('draggable')).toBe('false')
expect(row.hasAttribute('draggable')).toBe(false)
})
})

View File

@@ -364,10 +364,6 @@ export class CredentialsLocal extends Credentials {
// After the commit: a broken observer must never make the durable
// write look failed (an INVARIANT failure still rethrows).
this.notifyUpdated(ref)
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
},
})
})
}

View File

@@ -4,7 +4,7 @@
// editor's multi-line and CRLF discipline.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
@@ -68,17 +68,6 @@ describe('read-modify-write', () => {
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await ctx.credentials.set(ALPHA, 'nine')
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
})
it('creates the credentials directory owner-only', async () => {
const dir = await tempDir()
const home = join(dir, 'home')

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/host/apiproxy/README.md
README.md: cf8cf0aaa7e20436644b92a996b6f7a817b1dd31
README.zh.md: 91b3d1d0577d4a9a7df97d3c785e28bb21e47fd1
README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358
README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db

View File

@@ -24,6 +24,10 @@ Pending queued input is a live control-plane contract, not session history. The
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, 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.
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
@@ -49,5 +53,6 @@ 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`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); 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.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -24,6 +24,10 @@
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 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
@@ -49,5 +53,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**协议形状POST `/api/respond``RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项(先前预留的 `host.listModels` 已作为 `llm.models` 交付);未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -52,6 +52,7 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",

View File

@@ -17,6 +17,7 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -26,9 +27,14 @@ import {
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary,
SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
SessionSummary, SettingsNamespaceView, 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: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
@@ -66,9 +72,20 @@ import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
/** Read live abort state across awaits without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/**
* Message-boundary pagination: count maxMessages append-origin messages
* backwards from the window tail. Replacement copies never entered the
@@ -266,15 +283,22 @@ function summarize(session: Session, running: boolean): SessionSummary {
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
*/
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
async function summarizeCold(
persistence: SessionPersistence,
meta: SessionHeader,
signal?: AbortSignal,
): Promise<SessionSummary> {
signal?.throwIfAborted()
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
signal?.throwIfAborted()
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
signal?.throwIfAborted()
}
return {
sessionId: meta.id,
@@ -962,6 +986,62 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return operation
}
/**
* Build the session.list baseline shared by listing and search visibility.
* Attached sessions come from memory; servable cold sessions merge from
* persistence, and the final order is newest-first.
*/
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted()
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
const projections = listProjectionsFor(ctx, session.header, session)
return {
...summarize(session, agent?.status === 'running'),
...projections === undefined ? {} : { projections },
}
})
signal?.throwIfAborted()
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list(signal))
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
signal?.throwIfAborted()
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
signal?.throwIfAborted()
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
const settled = await Promise.allSettled(
batch.map(async (meta) => {
// Cold rows read the persisted projection cache only — never a
// log load; a session without a cache row simply has no column.
const projections = listProjectionsFor(ctx, meta, undefined)
return {
...await summarizeCold(persistence, meta, signal),
...projections === undefined ? {} : { projections },
}
}),
)
const summaries: SessionSummary[] = []
let rejected = false
let failure: unknown
for (const result of settled) {
if (result.status === 'fulfilled') {
summaries.push(result.value)
} else if (!rejected) {
rejected = true
failure = result.reason
}
}
if (rejected) throw failure
signal?.throwIfAborted()
items.push(...summaries)
}
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return items
}
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
const goals = ctx.get('goals')
@@ -1104,30 +1184,137 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Legacy logs without a cwd (pre-project stance) are not served — every
// session now records its project at create time.
async list(request) {
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
const projections = listProjectionsFor(ctx, session.header, session)
return {
...summarize(session, agent?.status === 'running'),
...projections === undefined ? {} : { projections },
}
return ok(request, { items: await listVisibleSessionSummaries() })
},
async search(request, signal) {
const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
code: 'cancelled',
message: 'session search was aborted',
details: {},
})
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(async (meta) => {
// Cold rows read the persisted projection cache only — never a
// log load; a session without a cache row simply has no column.
const projections = listProjectionsFor(ctx, meta, undefined)
return {
...await summarizeCold(persistence, meta),
...projections === undefined ? {} : { projections },
}
})))
if (isAborted(signal)) return cancelled()
const sessionQuery = ctx.get('sessionQuery')
if (sessionQuery === undefined) {
return err(request, {
code: 'internal',
message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
details: {},
})
}
try {
const visible = await listVisibleSessionSummaries(signal)
if (isAborted(signal)) return cancelled()
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
const visibleIds = new Set(visible.map(item => item.sessionId))
const authorized: SessionSearchItem[] = []
const acceptedIds = new Set<SessionId>()
const seenCursors = new Set<SessionSearchCursor>()
let cursor: SessionSearchCursor | undefined
let providerCallCount = 0
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(
`session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
)
}
providerCallCount++
const requestedCursor = cursor
const requestedPageLimit = providerPageLimit
let page
try {
page = await sessionQuery.searchSessions({
query: request.payload.query,
eventFilters: [
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
{ kind: 'surface', values: ['current'] },
],
limit: requestedPageLimit,
...requestedCursor === undefined ? {} : { cursor: requestedCursor },
}, { signal })
} catch (error: unknown) {
if (isAborted(signal)) return cancelled()
if (
requestedCursor === undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_INVALID_LIMIT'
&& requestedPageLimit > 1
) {
providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2))
continue
}
if (
requestedCursor !== undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_STALE_CURSOR'
) {
authorized.length = 0
acceptedIds.clear()
seenCursors.clear()
cursor = undefined
continue
}
throw error
}
if (isAborted(signal)) return cancelled()
const providerItemCount = page.items.length
if (providerItemCount > requestedPageLimit) {
throw new Error(
`session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`,
)
}
// 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.
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
|| hit.bestMatch.surface !== 'current'
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|| acceptedIds.has(hit.header.id)
) continue
const snippet = truncateUnicodeCodePoints(
hit.bestMatch.snippet,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
)
acceptedIds.add(hit.header.id)
authorized.push({
sessionId: hit.header.id,
snippet,
})
}
const nextCursor = page.nextCursor
if (nextCursor !== undefined) {
if (seenCursors.has(nextCursor)) {
throw new Error('session search provider repeated a continuation cursor')
}
seenCursors.add(nextCursor)
}
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
cursor = nextCursor
}
return ok(request, {
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)}`,
details: {},
})
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return ok(request, { items })
},
async create(request) {

View File

@@ -35,7 +35,8 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
@@ -67,5 +68,11 @@ export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
// ---- 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'

View File

@@ -22,6 +22,7 @@ import type { RpcResponse } from './rpc.ts'
*/
export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.search': SessionsApi['search']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']

View 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
}

View File

@@ -12,10 +12,15 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, 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>
@@ -62,6 +67,33 @@ export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'
items: z.array(sessionSummarySchema),
})
/** Fixed wire bound for one interactive sidebar query. */
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
/** session.search request payload. */
export const sessionSearchRequestSchema = z.object({
query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS)
.refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }),
}) satisfies z.ZodType<Wire<RequestPayload<'session.search'>>>
/** One session.search result. */
export const sessionSearchItemSchema = z.object({
sessionId: sessionIdSchema,
snippet: z.string().refine(
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>>
/** session.search response value. */
export const sessionSearchValueSchema = z.object({
items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT),
hasMore: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),

View File

@@ -169,11 +169,28 @@ export interface SessionSummary {
projections?: SessionProjectionsBlock
}
/** One session-content search result; display metadata stays owned by `session.list`. */
export interface SessionSearchItem {
sessionId: SessionId
/** Plain-text excerpt around the strongest matching visible message. */
snippet: string
}
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
/**
* Searches the current user/assistant/steering message surface across
* sessions visible to `list`. Results contain at most 20 sessions and carry
* no continuation cursor; `hasMore` asks the client to refine the query.
*/
search(
request: RpcRequest<{ query: string }>,
signal: AbortSignal,
): Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>>
/**
* Creates a real session and its idle agent. At most one of `workspaceId` /
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may

View File

@@ -26,6 +26,7 @@ import {
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionRenameValueSchema,
sessionSearchValueSchema,
sessionSelectModelValueSchema,
sessionUpdateQueueValueSchema,
} from '../api/sessions.schema.ts'
@@ -72,6 +73,7 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema
export interface IApiClient {
sessions: {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
@@ -140,6 +142,7 @@ export interface IApiClient {
*/
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
'session.list': sessionListValueSchema,
'session.search': sessionSearchValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
@@ -363,6 +366,7 @@ export abstract class AbstractApiClient implements IApiClient {
readonly sessions: IApiClient['sessions'] = {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
search: (payload, signal) => this.callUnary('session.search', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),

View File

@@ -23,6 +23,7 @@ import {
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionRenameRequestSchema,
sessionSearchRequestSchema,
sessionSelectModelRequestSchema,
sessionUpdateQueueRequestSchema,
} from '../api/sessions.schema.ts'
@@ -63,7 +64,8 @@ import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.sc
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
* Every invoke receives the carrier Request's signal; methods whose contract
* declares a signal parameter (command.execute) forward it, the rest ignore it.
* declares a signal parameter (session.search and command.execute) forward it,
* the rest ignore it.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
@@ -74,6 +76,7 @@ type UnaryRoutes = {
const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },

View File

@@ -0,0 +1,880 @@
/**
* 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 { stat } from 'node:fs/promises'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
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'
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, stat: vi.fn(actual.stat) }
})
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', createUserMessage({
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',
},
},
],
}))
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: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
SessionSearchRequest,
{ signal: AbortSignal },
]
expect(query).toEqual({
query: 'matching answer',
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('rejects snippets whose provider provenance violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const withBestMatch = (
index: number,
bestMatch: Partial<SessionSearchHit['bestMatch']>,
): SessionSearchHit => {
const base = hit('visible', index)
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
],
}),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('match'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
hasMore: false,
},
})
})
it('pages the globally ranked stream until the 20-item Host boundary is known', 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 })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({
items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
nextCursor: 'page-2',
})
.mockResolvedValueOnce({ items: items.slice(19) })
ctx.provide('sessionQuery', {
searchSessions,
} 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')
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
})
it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', 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 })
}
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
const limit = providerRequest.limit
if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
if (limit > 10) return Promise.reject(invalidLimit)
const offset = providerRequest.cursor === undefined
? 0
: Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
const end = Math.min(items.length, offset + limit)
return Promise.resolve({
items: items.slice(offset, end),
...end < items.length ? { nextCursor: `offset-${end}` } : {},
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('adaptive-page-limit'),
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.map(item => item.sessionId))
.toEqual(items.slice(0, 20).map(item => item.header.id))
expect(searchSessions.mock.calls.map(([providerRequest]) => ({
limit: providerRequest.limit,
cursor: providerRequest.cursor,
}))).toEqual([
{ limit: 20, cursor: undefined },
{ limit: 10, cursor: undefined },
{ limit: 10, cursor: 'offset-10' },
{ limit: 10, cursor: 'offset-20' },
])
})
it('counts a page-limit probe inside the 100-call budget', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length === 1) {
expect(providerRequest).toMatchObject({ limit: 20 })
return Promise.reject(invalidLimit)
}
expect(providerRequest.limit).toBe(10)
return Promise.resolve({
items: [],
nextCursor: `page-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('endless-pages'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('100-call work budget')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
const ctx = await baseContext()
const oldOnly = hit('old-only', 0)
const shared = hit('shared', 1)
const freshFirst = hit('fresh-first', 2)
const freshLast = hit('fresh-last', 3)
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const late = hit('late-visible', 4)
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
switch (searchSessions.mock.calls.length) {
case 1:
expect(providerRequest).toMatchObject({ limit: 20 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.reject(invalidLimit)
case 2:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [oldOnly, shared],
nextCursor: 'old-cursor',
})
case 3:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
ctx.sessions.create(late.header.id, { meta: late.header })
return Promise.reject(stale)
case 4:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [freshFirst, shared],
nextCursor: 'old-cursor',
})
case 5:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
return Promise.resolve({ items: [freshLast, late] })
default:
return Promise.reject(new Error('unexpected provider call'))
}
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('stale-restart'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [
{ sessionId: 'fresh-first', snippet: 'match 2' },
{ sessionId: 'shared', snippet: 'match 1' },
{ sessionId: 'fresh-last', snippet: 'match 3' },
],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledTimes(5)
})
it('counts continuous stale restarts against the 100-call budget', async () => {
const ctx = await baseContext()
const partial = hit('partial')
ctx.sessions.create(partial.header.id, { meta: partial.header })
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length > 100) {
return Promise.reject(new Error('provider was called after the shared budget'))
}
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
return Promise.resolve({
items: [partial],
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('stale-churn'),
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('100-call work budget')
expect(response.result).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('gives abort priority over a coincident stale continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.reject(stale)
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('abort-stale'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not retry a stale first-page failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
'provider generation changed before paging',
'SESSION_QUERY_STALE_CURSOR',
)))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('first-page-stale'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response.result).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledOnce()
})
it('does not adapt an invalid-limit continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockRejectedValueOnce(new SessionQueryError(
'continuation limit is invalid',
'SESSION_QUERY_INVALID_LIMIT',
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('continuation-invalid-limit'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls.map(([providerRequest]) => (
providerRequest as SessionSearchRequest
).limit))
.toEqual([20, 20])
})
it('stops page-limit adaptation at one item', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
new SessionQueryError(
`provider rejects ${providerRequest.limit}`,
'SESSION_QUERY_INVALID_LIMIT',
),
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('minimum-page-limit'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
.toEqual([20, 10, 5, 2, 1])
})
it('gives abort priority over a coincident invalid first-page limit', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn(() => {
controller.abort()
return Promise.reject(new SessionQueryError(
'provider rejects 20',
'SESSION_QUERY_INVALID_LIMIT',
))
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('abort-invalid-limit'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledOnce()
})
it('rejects an oversized provider page', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('oversized-page'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
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')
})
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 = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (providerRequest.limit === 20) {
return Promise.reject(new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
))
}
return Promise.resolve({ items: oversized })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('adapted-oversized-page'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('returned 11 items; maximum is 10')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const expected = `${'x'.repeat(239)}😀`
const overlong = {
...visible,
bestMatch: {
...visible.bestMatch,
snippet: `${expected}${'y'.repeat(10_000)}`,
},
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items: [overlong] }),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('bounded-snippet'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: expected }],
hasMore: false,
},
})
})
it('fails closed when the provider repeats a continuation cursor', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('repeated-cursor'),
new AbortController().signal,
)
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error).toMatchObject({ code: 'internal' })
expect(response.result.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('validates a repeated cursor before accepting the authorized lookahead', 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 })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('repeated-lookahead-cursor'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response.result).not.toHaveProperty('value')
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not count duplicate session ids toward the result or lookahead boundary', 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 })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
.mockResolvedValueOnce({ items: items.slice(20) })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('duplicate-pages'),
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.map(item => item.sessionId)).toEqual(
items.slice(0, 20).map(item => item.header.id),
)
expect(searchSessions).toHaveBeenCalledTimes(3)
})
it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.resolve({ items: [] })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('cancel-continuation'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
for (const call of searchSessions.mock.calls) {
expect(call[1]).toEqual({ signal: controller.signal })
}
})
it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
const ctx = await baseContext()
const cold = Array.from(
{ length: 32_751 },
(_, index) => header(`cold-${index}`, `/cold-${index}`),
)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
items: [hit('cold-32750')],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('large corpus'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const list = vi.fn((signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
return Promise.resolve(cold)
})
let locateCalls = 0
ctx.provide('sessionPersistence', {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('cancel-during-visibility'),
controller.signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(searchSessions).not.toHaveBeenCalled()
})
it('awaits every started cold-summary stat before returning cancellation', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
const statMock = vi.mocked(stat)
statMock.mockClear()
for (const gate of statGates) {
statMock.mockImplementationOnce((() => gate.promise) as never)
}
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
let settled = false
const responsePromise = createApiProxy(ctx, defaults).sessions.search(
request('cancel-during-cold-stats'),
controller.signal,
).finally(() => {
settled = true
})
await vi.waitFor(() => {
expect(statMock).toHaveBeenCalledTimes(16)
})
controller.abort()
statGates[0]!.resolve({ mtimeMs: 101 })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
const response = await responsePromise
expect(response.result).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).not.toHaveBeenCalled()
})
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

@@ -35,6 +35,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: [],
@@ -141,6 +142,44 @@ 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('rejects an overlong session-search snippet at the client value boundary', async () => {
const api = scriptedApi({
sessions: {
search: request => ok(request, {
items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }],
hasMore: false,
}),
},
})
await expect(client(api).sessions.search({ query: 'message' }))
.rejects.toThrow(/240 Unicode code points/)
})
it('routes session fork with its optional cut anchor through the wire', async () => {
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
const api = scriptedApi({

View File

@@ -22,6 +22,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 } } }
},
@@ -248,6 +268,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/updateQueue/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.models({ sessionId: 's' as never })).result.ok).toBe(true)
const selected = await c.sessions.selectModel({
@@ -339,6 +363,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
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', headers: { 'content-type': 'application/json' }, 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')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {

View File

@@ -10,7 +10,8 @@ import {
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema,
sessionSelectModelValueSchema, sessionSummarySchema,
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
} from '../src/api/sessions.schema.ts'
import {
@@ -150,6 +151,36 @@ 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: 's1', snippet: '😀'.repeat(240) }],
hasMore: false,
}).items[0]?.snippet).toBe('😀'.repeat(240))
expect(() => sessionSearchValueSchema.parse({
items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }],
hasMore: false,
})).toThrow(/240 Unicode code points/)
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')

View File

@@ -47,6 +47,9 @@
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},

View File

@@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
' config:',
` host: '${bindHost}'`,
' port: 0',
' portConflict: increment',
` distIndex: '${distIndex}'`,
`- name: '${AUTO}'`,
'',

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/session-query/session-query-sqlite/README.md
README.md: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2
README.zh.md: 33afe99c3e065e231692e162549aef8e28e542e3
README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1
README.zh.md: 6eb1f3bca034a974128eb33381b5d3c383b55d12

View File

@@ -16,6 +16,8 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def
The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, non-mutatingly inspects only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Session queries never invoke the persistence backend's crash-repairing `load()`; an owner attaching during inspection cannot mutate its log, and the stable-observation retry makes the result live-preferred. The TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and an unchanged same-store reopen perform no full durable-log inspection; switching stores, or observing new, changed, deleted, or externally load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries.
`openAt: startup` is the default: service activation imports `node:sqlite`, opens the handle, and fails before publication when the index is invalid. `openAt: first-search` publishes the service as ACTIVE without importing the SQLite module or opening a handle; the first concurrent searches share one readiness promise, and disposal before any search opens nothing. This mode supports compositions that need clean Node 22 startup output by deferring SQLite's experimental warning until the first actual search; it does not suppress a warning at that point. An invalid database likewise fails the first search instead of service activation.
Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows.
The database is disposable but reset is guarded: every recognized schema version rejects unknown user tables before mutating journal mode, and only a recognized incompatible schema containing derived tables rebuilds in place. An unrelated or canonical database is refused. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned.
@@ -25,6 +27,7 @@ The database is disposable but reset is guarded: every recognized schema version
| Key | Default | Contract |
|---|---:|---|
| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. Missing filesystem paths are created owner-only on POSIX filesystems. |
| `openAt` | `startup` | `startup` opens before service activation completes; `first-search` defers the SQLite module and handle until search. |
| `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. |
| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. |
| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. |

View File

@@ -16,6 +16,8 @@
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,仅以不修改日志的方式检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`检查期间接入的活动所有者无法修改其日志稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该活动所有者脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时所有者消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
该数据库虽可丢弃重建,但 reset 操作受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700``0600`SQLite sidecar 继承数据库 mode现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态由连接持有。
@@ -25,6 +27,7 @@
| 键 | 默认值 | 契约 |
|---|---:|---|
| `path` | 必填 | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 |
| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开。 |
| `journalMode` | `wal` | `wal``delete``truncate``persist`。 |
| `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
| `maxLimit` | `100` | 接受的最大请求分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |

View File

@@ -5,7 +5,7 @@
*/
import { createHash, randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import type { DatabaseSync } from 'node:sqlite'
import { Context, Service, type Fiber } from 'cordis'
import z from 'schemastery'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -82,14 +82,19 @@ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240
// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue.
const STABLE_OBSERVATION_ATTEMPTS = 2
/** SQLite module/handle opening phase. */
export type OpenAt = 'startup' | 'first-search'
/** Combined session-query configuration backed by SQLite full-text search. */
export interface Config extends SessionQueryConfig {
/**
* Dedicated derived-index path; `:memory:` is supported for tests. Missing
* directories and database files are created owner-only on POSIX filesystems;
* existing modes are preserved.
* Dedicated derived-index path; `:memory:` is supported for ephemeral
* indexes. Missing directories and database files are created owner-only on
* POSIX filesystems; existing modes are preserved.
*/
path: string
/** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */
openAt?: OpenAt
/** SQLite journal mode. Defaults to `wal`. */
journalMode?: JournalMode
/** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */
@@ -104,6 +109,7 @@ export interface Config extends SessionQueryConfig {
interface ResolvedConfig {
path: string
openAt: OpenAt
journalMode: JournalMode
defaultLimit: number
maxLimit: number
@@ -185,6 +191,7 @@ export class SessionQuerySqlite extends SessionQueryService {
static Config: z<Config> = z.object({
path: z.string().required(),
openAt: z.union(['startup', 'first-search'] as const).default('startup'),
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT),
maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT),
@@ -201,7 +208,7 @@ export class SessionQuerySqlite extends SessionQueryService {
readonly config: ResolvedConfig
private readonly _instance = randomUUID()
private readonly _ready: Promise<void>
private _ready: Promise<void> | undefined
private _db: DatabaseSync | undefined
private _persistenceBinding: PersistenceBinding = { identity: Symbol() }
private _lastPersistenceIdentity: symbol | undefined
@@ -218,7 +225,6 @@ export class SessionQuerySqlite extends SessionQueryService {
// register `ctx.sessionQuery`; keep that same validated value afterward.
super(ctx, config = resolveConfig(config))
this.config = config as ResolvedConfig
this._ready = this._open()
this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
const service = childCtx.sessionPersistence
const binding = { identity: Symbol(), service }
@@ -235,9 +241,9 @@ export class SessionQuerySqlite extends SessionQueryService {
ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close')
}
/** Open the index before Cordis publishes this combined service as active. */
/** Open eagerly only when activation owns the configured readiness boundary. */
protected async [Service.init](): Promise<void> {
await this._ensureReady(undefined)
if (this.config.openAt === 'startup') await this._ensureReady(undefined)
}
override async searchSessions(
@@ -306,10 +312,12 @@ export class SessionQuerySqlite extends SessionQueryService {
private async _close(): Promise<void> {
this._closed = true
await this._tail
try {
await this._ready
} catch {
// Opening already closed a partially-created handle; disposal only waits.
if (this._ready !== undefined) {
try {
await this._ready
} catch {
// Opening already closed a partially-created handle; disposal only waits.
}
}
this._db?.close()
this._db = undefined
@@ -325,6 +333,7 @@ export class SessionQuerySqlite extends SessionQueryService {
}
private async _ensureReady(signal: AbortSignal | undefined): Promise<void> {
this._ready ??= this._open()
try {
await waitWithAbort(this._ready, signal)
} catch (error: unknown) {
@@ -629,6 +638,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 (
@@ -956,6 +967,7 @@ function invalidCursor(cause: unknown): SessionQueryError {
function resolveConfig(config: Config): ResolvedConfig {
const resolved: ResolvedConfig = {
path: config.path,
openAt: config.openAt ?? 'startup',
journalMode: config.journalMode ?? 'wal',
defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT,
maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT,
@@ -967,6 +979,8 @@ function resolveConfig(config: Config): ResolvedConfig {
if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) {
throw invalidConfig('path must not be blank')
}
const openPhases: readonly string[] = ['startup', 'first-search']
if (!openPhases.includes(resolved.openAt)) throw invalidConfig('openAt is not supported')
assertPageLimit('defaultLimit', resolved.defaultLimit)
assertPageLimit('maxLimit', resolved.maxLimit)
assertPositiveInteger('snippetChars', resolved.snippetChars)

View File

@@ -1,6 +1,6 @@
/** SQLite schema for the disposable session full-text read model. */
import { DatabaseSync } from 'node:sqlite'
import type { DatabaseSync } from 'node:sqlite'
import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
@@ -49,6 +49,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
await createDatabaseFile(actual)
}
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(actual)
try {
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
@@ -185,16 +185,19 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli
}
describe('SQLite session search', () => {
it('defaults and validates persisted inspection concurrency through its Cordis config', async () => {
it('defaults and validates opening policy and persisted inspection concurrency through its Cordis config', async () => {
const defaultCtx = await liveContext()
expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.openAt).toBe('startup')
expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
.toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
const configuredValue = 2
const configured = new SessionQuerySqlite.Config({
path: ':memory:',
openAt: 'first-search',
persistedInspectConcurrency: configuredValue,
})
expect(configured.openAt).toBe('first-search')
expect(configured.persistedInspectConcurrency).toBe(configuredValue)
const configuredCtx = await liveContext(configured)
expect((configuredCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
@@ -206,6 +209,72 @@ describe('SQLite session search', () => {
persistedInspectConcurrency,
})).toThrow()
}
expect(() => new SessionQuerySqlite.Config({
path: ':memory:',
openAt: 'later' as never,
})).toThrow()
})
it('mounts and disposes first-search mode without opening its database', async () => {
const path = await temporaryPath('unopened.db')
const ctx = new Context()
await ctx.plugin(SessionStore)
const search = await ctx.plugin(SessionQuerySqlite, {
path,
openAt: 'first-search',
})
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
await search.dispose()
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('opens once on the first search and reuses readiness for later searches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQuerySqlite, {
path: ':memory:',
openAt: 'first-search',
})
const service = ctx.sessionQuery as SessionQuerySqlite
const internals = service as unknown as { _open(): Promise<void> }
const open = vi.spyOn(internals, '_open')
await expect(service.searchSessions({ query: 'first' })).resolves.toEqual({ items: [] })
await expect(service.searchSessions({ query: 'second' })).resolves.toEqual({ items: [] })
expect(open).toHaveBeenCalledOnce()
})
it('shares one readiness promise across concurrent first searches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQuerySqlite, {
path: ':memory:',
openAt: 'first-search',
})
const service = ctx.sessionQuery as SessionQuerySqlite
const internals = service as unknown as { _open(): Promise<void> }
const originalOpen = internals._open.bind(internals)
const release = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<undefined>()
const open = vi.spyOn(internals, '_open').mockImplementation(async () => {
started.resolve(undefined)
await release.promise
await originalOpen()
})
const first = service.searchSessions({ query: 'first' })
const second = service.searchSessions({ query: 'second' })
await started.promise
expect(open).toHaveBeenCalledOnce()
release.resolve(undefined)
await expect(Promise.all([first, second])).resolves.toEqual([
{ items: [] },
{ items: [] },
])
expect(open).toHaveBeenCalledOnce()
})
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
@@ -230,6 +299,36 @@ 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,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: 'private-chain-marker' },
{ type: 'text', text: 'visible-answer-marker' },
],
source: { 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')
@@ -538,6 +637,7 @@ describe('SQLite session search', () => {
{ path: ':memory:', persistedInspectConcurrency: 0 },
{ path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 },
{ path: ':memory:', defaultLimit: 3, maxLimit: 2 },
{ path: ':memory:', openAt: 'later' },
{ path: ':memory:', journalMode: 'memory' },
]) {
const direct = new Context()
@@ -1141,7 +1241,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') })
@@ -1259,6 +1359,30 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
}
})
it('defers an invalid database failure only in first-search mode', async () => {
const path = await temporaryPath('lazy-invalid.db')
const foreign = new DatabaseSync(path)
foreign.exec('CREATE TABLE canonical(value TEXT)')
foreign.close()
const lazyCtx = new Context()
await lazyCtx.plugin(SessionStore)
const lazy = await lazyCtx.plugin(SessionQuerySqlite, {
path,
openAt: 'first-search',
})
expect(lazyCtx.sessionQuery).toBeInstanceOf(SessionQuerySqlite)
await expect(lazyCtx.sessionQuery.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await lazy.dispose()
const eagerCtx = new Context()
await eagerCtx.plugin(SessionStore)
await expect(eagerCtx.plugin(SessionQuerySqlite, { path }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(eagerCtx.sessionQuery).toBeUndefined()
})
it.each(['sessions', 'events'] as const)(
'forwards one exact reconciliation signal through both snapshot lists and persisted inspection for %s search',
async (scope) => {

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/session-query/session-query/README.md
README.md: ebc577975f874a1a60c84061f9148282742bfdf2
README.zh.md: df504e8ae5172fa783769a6fa39830c446688e29
README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063
README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More