From a43020742719b2bbf94334b9c42e3f46097314a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 26 Jul 2026 14:09:31 +0800 Subject: [PATCH 001/709] feat(web): retry transient model requests --- ...2026-06-21-bounded-llm-request-recovery.md | 8 +- apps/cli/README.md | 2 +- apps/cli/cordis.yml | 3 + apps/cli/package.json | 1 + apps/web/tests/session-title.snapshot.ts | 75 ++++++++++++- apps/web/tests/smoke-real.e2e.ts | 92 ++++++++++++++++ apps/web/tests/snapshots/model-retry.json | 27 +++++ docs/config-catalog.md | 2 +- .../client/connection/src/client/fixture.ts | 57 ++++++++++ .../client/connection/tests/fixture.spec.ts | 8 ++ packages/client/runtime/README.md | 4 + packages/client/runtime/package.json | 1 + packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 18 +++- .../runtime/src/client/sessions/session.ts | 91 ++++++++++++---- packages/client/runtime/tests/event-script.ts | 16 +++ packages/client/runtime/tests/session.spec.ts | 66 ++++++++++++ packages/client/runtime/tsconfig.json | 3 + packages/client/ui-conversation/README.md | 2 + .../src/client/chat/ChatView.tsx | 20 +++- .../src/client/chat/MessageItem.module.css | 100 ++++++++++++++++++ .../src/client/chat/MessageItem.tsx | 64 +++++++++-- .../src/client/chat/chat-flow.ts | 17 ++- .../tests/chat-branch-tails.spec.tsx | 79 +++++++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 43 +++++++- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/package.json | 5 + packages/llm/llm-retry/src/index.ts | 2 + packages/llm/llm-retry/src/types.ts | 11 ++ packages/llm/llm-retry/tests/retry.spec.ts | 9 +- pnpm-lock.yaml | 6 ++ tsconfig.base.json | 1 + 32 files changed, 791 insertions(+), 46 deletions(-) create mode 100644 apps/web/tests/snapshots/model-retry.json create mode 100644 packages/llm/llm-retry/src/types.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 932318da4f..3ec72eb19a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -68,11 +68,11 @@ For an eligible failure with budget remaining, the one-based transient retry cou The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation and exports the payload through its browser-safe `./types` subpath; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. The shipped Web/headless composition also loads it, so browser and command-line requests share the TUI defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. ### Make one layer own visible attempts @@ -90,7 +90,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. TUI and Web render live chunks while a step is open, then clear that transient view and retain replayable status when `llm/retry` identifies the failed step. Web projects consecutive same-turn retry events into one stable row updated to the latest attempt, counts its delay down in ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -124,7 +124,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/apps/cli/README.md b/apps/cli/README.md index 2afd9c346b..301544d1ab 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and use the same bounded transient model-request retry policy as the TUI. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). ## Install (developer machine) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..4e42b1c725 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -59,6 +59,9 @@ config: agents: [] +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + # The native DeepSeek adapter; reads the key/base-url the boot's layered # .env loading (cwd then $DSH_HOME) left in the environment. - id: llm-deepseek diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..6cf696f542 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..c24fd12a63 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -25,6 +25,9 @@ const bundles = new Map(PLUGINS.map(plugin => [ interface FixtureTiming { appendTitle(id: string, title: string): void + beginModelRetry(id: string): void + scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + completeModelRetry(id: string): void } interface FixtureWindow extends Window { @@ -78,7 +81,7 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do return { sidebar, breadcrumb, documentTitle: document.title } } -it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { +function bootFixtureApp(): void { const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { @@ -92,18 +95,26 @@ it('projects initial and revised durable titles through the built nine-plugin fi void entry.run() unmount = () => { entry.dispose() } }) +} +async function selectFixtureSession(): Promise { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const projectCount = await within(tree).findByText('4 sessions') const projectRow = projectCount.closest('[role="treeitem"]') if (projectRow === null) throw new Error('fixture project row missing') fireEvent.click(projectRow) - const initialLabel = 'Fixture 历史会话' - const initialRowLabel = await screen.findByText(initialLabel) + const initialRowLabel = await screen.findByText('Fixture 历史会话') const initialRow = initialRowLabel.closest('[role="treeitem"]') if (initialRow === null) throw new Error('fixture session row missing') fireEvent.click(initialRow) +} + +it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { + bootFixtureApp() + await selectFixtureSession() + + const initialLabel = 'Fixture 历史会话' await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) }) const initial = titleSurfaces(initialLabel) @@ -116,3 +127,61 @@ it('projects initial and revised durable titles through the built nine-plugin fi await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`) .toMatchFileSnapshot('./snapshots/session-title.json') }) + +it('retracts a failed stream at llm/retry and retains the durable notice after recovery', async () => { + bootFixtureApp() + await selectFixtureSession() + const timing = (globalThis as Record).__fxTiming as FixtureTiming + + act(() => { timing.beginModelRetry('fx-alpha') }) + const partial = await screen.findByText('应撤回的半截回复') + const beforeRetry = { partial: partial.textContent } + + act(() => { timing.scheduleModelRetry('fx-alpha') }) + const firstNotice = await screen.findByRole('status') + await waitFor(() => { expect(screen.queryByText('应撤回的半截回复')).toBeNull() }) + const disclosure = firstNotice.closest('details') + if (disclosure === null) throw new Error('retry disclosure missing') + const firstRetry = { + notice: firstNotice.textContent, + rows: screen.getAllByRole('status').length, + } + + act(() => { timing.scheduleModelRetry('fx-alpha', 2, 1_500) }) + const notice = screen.getByRole('status') + await waitFor(() => { expect(notice.textContent).toContain('(2/2)') }) + const latestDisclosure = notice.closest('details') + const summary = notice.closest('summary') + if (latestDisclosure === null || summary === null) throw new Error('latest retry disclosure missing') + await waitFor(() => { expect(screen.queryByText('第 2 次应撤回的回复')).toBeNull() }) + const scheduled = { + partialVisible: screen.queryByText('应撤回的半截回复') !== null + || screen.queryByText('第 2 次应撤回的回复') !== null, + notice: notice.textContent, + rows: screen.getAllByRole('status').length, + reusedDisclosure: latestDisclosure === disclosure, + detailsOpen: latestDisclosure.open, + animated: latestDisclosure.dataset.active === 'true', + } + fireEvent.click(summary) + const expanded = { + detailsOpen: latestDisclosure.open, + delay: screen.getByText('重试延迟:').parentElement?.textContent, + failure: screen.getByText('失败原因:').parentElement?.textContent, + } + + act(() => { timing.completeModelRetry('fx-alpha') }) + const recovered = await screen.findByText('重试后的完整回复') + await waitFor(() => { expect(screen.getByRole('status').textContent).toContain('已重试') }) + const completedNotice = screen.getByRole('status') + const completedDisclosure = completedNotice.closest('details') + if (completedDisclosure === null) throw new Error('completed retry disclosure missing') + const completed = { + recovered: recovered.textContent, + retryNoticeStillVisible: completedNotice.textContent, + animated: completedDisclosure.dataset.active === 'true', + } + + await expect(`${JSON.stringify({ beforeRetry, firstRetry, scheduled, expanded, completed }, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/model-retry.json') +}) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..eb6ae6d26a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -271,6 +271,98 @@ describe('dsh web keyless CLI smoke', () => { rmSync(workspace, { recursive: true, force: true }) } }) + + it('retries a partial transport failure through the shipped Web composition', async () => { + requireDist() + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-')) + const promptMarker = 'WEB_RETRY_REQUEST' + const recoveredMarker = 'WEB_RETRY_RECOVERED' + let mainAttempts = 0 + const provider = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] } + const titleRequest = parsed.max_tokens === 64 + const mainRequest = !titleRequest && body.includes(promptMarker) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + if (!mainRequest) { + response.end([ + 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}', + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + return + } + mainAttempts++ + if (mainAttempts === 1) { + response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n') + setTimeout(() => { response.destroy() }, 20) + return + } + response.end([ + `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`, + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) + const address = provider.address() + if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port') + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: workspace, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-retry', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_HOME: join(workspace, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = await waitForReadyLine(child) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: promptMarker }], + }) + let page: HistoryPage | undefined + await expect.poll(async () => { + page = await history(baseUrl, created.sessionId) + return hasAssistantMarker(page, recoveredMarker) + }, { timeout: 20_000 }).toBe(true) + if (page === undefined) throw new Error('retry history was not observed') + const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event + expect(mainAttempts).toBe(2) + expect(retry?.data).toMatchObject({ + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + failure: { code: 'TRANSPORT' }, + }) + expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED') + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + await new Promise(resolveClose => provider.close(() => { resolveClose() })) + rmSync(workspace, { recursive: true, force: true }) + } + }, 30_000) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { diff --git a/apps/web/tests/snapshots/model-retry.json b/apps/web/tests/snapshots/model-retry.json new file mode 100644 index 0000000000..df2ce854da --- /dev/null +++ b/apps/web/tests/snapshots/model-retry.json @@ -0,0 +1,27 @@ +{ + "beforeRetry": { + "partial": "应撤回的半截回复" + }, + "firstRetry": { + "notice": "正在重试模型请求(1/2) · 1s", + "rows": 1 + }, + "scheduled": { + "partialVisible": false, + "notice": "正在重试模型请求(2/2) · 2s", + "rows": 1, + "reusedDisclosure": true, + "detailsOpen": false, + "animated": true + }, + "expanded": { + "detailsOpen": true, + "delay": "重试延迟:1500ms", + "failure": "失败原因:连接被重置" + }, + "completed": { + "recovered": "重试后的完整回复", + "retryNoticeStillVisible": "已重试模型请求(2/2) · 2s", + "animated": false + } +} diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6c7627aca4..7793fa5236 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -708,7 +708,7 @@ export interface Config { } ``` -Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:41`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 53c18dca5c..24ad94451d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -425,6 +425,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { let failNextHistory = false /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */ const streamBreakers = new Set<() => void>() + /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */ + const retryScenarios = new Map() // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which // is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let @@ -448,6 +450,61 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) }, + /** Open one failed model step whose partial remains visible until llm/retry arrives. */ + beginModelRetry(id: string): void { + const sessionId = sid(id) + const turn = nextTurn.get(sessionId) ?? 0 + nextTurn.set(sessionId, turn + 1) + retryScenarios.set(sessionId, { turn, failedStep: 0 }) + setRunning(sessionId, true) + append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } }) + append(sessionId, { type: 'step/start', data: { turn, step: 0 } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } }) + append(sessionId, { type: 'step/end', data: { turn, step: 0 } }) + }, + /** Record one retry decision, synthesizing the later failed step when needed. */ + scheduleModelRetry(id: string, retry = 1, delayMs = 450): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + const failedStep = retry - 1 + if (failedStep > scenario.failedStep) { + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: failedStep } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: failedStep } }) + scenario.failedStep = failedStep + } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: failedStep, retry, maxRetries: 2, delayMs, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }, + }) + }, + /** Finish the timing-hook retry with a finalized response on the next step. */ + completeModelRetry(id: string): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + retryScenarios.delete(sessionId) + const step = scenario.failedStep + 1 + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step } }) + append(sessionId, { + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: scenario.turn, step, content: text('重试后的完整回复'), + provenance: { provider: 'fixture', model: 'fx-1' }, + }, + }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step } }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) + setRunning(sessionId, false) + }, /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..e7912dc34b 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -19,6 +19,9 @@ interface TimingHooks { failNextHistory(): void appendUser(id: string, msg: string): void appendTitle(id: string, title: string): void + beginModelRetry(id: string): void + scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + completeModelRetry(id: string): void appendSilent(id: string, msg: string): void breakStreams(): void } @@ -489,9 +492,14 @@ describe('createFixtureApi', () => { hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') hooks.appendTitle('fx-alpha', 'Fixture 修订标题') + hooks.beginModelRetry('fx-alpha') + hooks.scheduleModelRetry('fx-alpha') + hooks.completeModelRetry('fx-alpha') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true) + expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 6b697cbed9..4c09bfd6e8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,6 +16,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. +## Model retry projection + +The Session object validates plugin-owned `llm/retry` payloads at the event wire boundary. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. + ## Model Experience None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 4bd95595c1..d4dfa8b526 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "immer": "^10.1.1", "react": "^18.2.0", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 830d1b8249..bea041e422 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -25,7 +25,7 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, + ConversationSnapshot, ModelRetryNode, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 78d1eeabf5..886168b581 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' @@ -87,6 +88,20 @@ export interface ContextMessageNode { meta?: unknown } +/** Durable notice that a closed failed step is waiting for a model-request retry. */ +export interface ModelRetryNode { + kind: 'model-retry' + seq: number + /** Unix epoch ms from the llm/retry session event. */ + time: number + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmRetryEventData['failure'] +} + /** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' @@ -124,6 +139,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | ModelRetryNode | ToolResultNode | UnknownSurfaceNode @@ -206,7 +222,7 @@ export interface PendingPrompt { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Surface fold product (finalized conversation nodes in surface order). */ + /** Finalized surface events and durable operational notices in event order. */ nodes: readonly ConversationNode[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 396d0aa798..d3fc31659b 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,6 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, @@ -52,9 +53,9 @@ export class Session implements ObservableSnapshot { private readonly foldAdapter = new FoldAdapter() private partial: PartialAccumulator | null = null private openCalls = new Map() - /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq. - * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ - private frozenNodes: ConversationNode[] = [] + /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. + * Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ + private derivedNodes: ConversationNode[] = [] private pending = new Map() // Revision counters preserve array identity when derived content is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every @@ -64,8 +65,8 @@ export class Session implements ObservableSnapshot { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - private frozenRev = 0 - private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + private derivedRev = 0 + private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -609,8 +610,27 @@ export class Session implements ObservableSnapshot { } /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk accumulation / partial clear on finalize / openCalls add-remove. */ + * chunk/retry projection and openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { + const eventType: string = event.type + if (eventType === 'llm/retry') { + const data = parseRetryEventData(event.data) + if (data === null) { + console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) + return + } + if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { + this.partial = null + } + this.derivedNodes.push({ + kind: 'model-retry', + seq: event.seq, + time: event.time, + ...data, + }) + this.derivedRev++ + return + } switch (event.type) { case 'assistant/chunk': { const { turn, step, chunk } = event.data @@ -649,12 +669,12 @@ export class Session implements ObservableSnapshot { const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'assistant', seq: event.seq - 0.9, time: event.time, turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) - this.frozenRev++ + this.derivedRev++ } this.partial = null } @@ -664,7 +684,7 @@ export class Session implements ObservableSnapshot { this.openCalls.delete(callId) this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, callId, call: { name: call.name, argsRaw: call.argsRaw }, @@ -672,7 +692,7 @@ export class Session implements ObservableSnapshot { content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) - this.frozenRev++ + this.derivedRev++ } return } @@ -681,15 +701,15 @@ export class Session implements ObservableSnapshot { } } - /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps + /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same retry notices and interrupted nodes. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() this.callsRev++ - this.frozenNodes = [] - this.frozenRev++ + this.derivedNodes = [] + this.derivedRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -704,17 +724,17 @@ export class Session implements ObservableSnapshot { private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() - // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. - // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its + // Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order. + // The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its // reference across snapshot swaps (§A.9.4). let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) { + if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) { nodes = this.nodesCache.value } else { - nodes = this.frozenNodes.length === 0 + nodes = this.derivedNodes.length === 0 ? folded - : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes } + : [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) + this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes } } if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } @@ -752,6 +772,37 @@ function rpcErrorMessage(error: RpcError): string { return `${error.code}: ${error.message}` } +/** Validate the plugin-owned payload at the session-event wire boundary. */ +function parseRetryEventData(value: unknown): LlmRetryEventData | null { + if (value === null || typeof value !== 'object') return null + const data = value as Record + const failure = data.failure + if (failure === null || typeof failure !== 'object') return null + const failureData = failure as Record + if (!nonNegativeInteger(data.turn) + || !nonNegativeInteger(data.step) + || !positiveInteger(data.retry) + || !positiveInteger(data.maxRetries) + || data.retry > data.maxRetries + || typeof data.delayMs !== 'number' + || !Number.isFinite(data.delayMs) + || data.delayMs < 0 + || typeof failureData.message !== 'string' + || typeof failureData.code !== 'string') return null + const optionalNumbers = [failureData.status, failureData.providerRetryAfterMs] + if (optionalNumbers.some(item => item !== undefined && (typeof item !== 'number' || !Number.isFinite(item)))) return null + if (failureData.requestId !== undefined && typeof failureData.requestId !== 'string') return null + return data as unknown as LlmRetryEventData +} + +function nonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} + +function positiveInteger(value: unknown): value is number { + return nonNegativeInteger(value) && value > 0 +} + /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). Monotone per session diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index b567800c9b..b9d6556a40 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -28,6 +28,22 @@ export const ev = { at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), + retry: ( + seq: number, + turn: number, + step = 0, + retry = 1, + maxRetries = 2, + delayMs = 500, + message = 'temporary transport failure', + ): SessionEvent => + at(seq, { + type: 'llm/retry', + data: { + turn, step, retry, maxRetries, delayMs, + failure: { code: 'TRANSPORT', message }, + }, + }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 136709b20c..320fee9f05 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -119,6 +119,72 @@ describe('live event path', () => { expect((last as { interrupted?: true }).interrupted).toBeUndefined() }) + it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + const retryTurn = [ + ev.turnStart(6, 1), + ev.user(7, '请重试'), + ev.stepStart(8, 1), + ev.chunkStart(9, 1), + ev.chunkText(10, 1, '不完整回复'), + ev.stepEnd(11, 1), + ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'), + ev.stepStart(13, 1, 1), + ev.assistant(14, 1, '完整回复', 1), + ev.stepEnd(15, 1, 1), + ev.turnEnd(16, 1), + ] + for (const event of retryTurn.slice(0, 7)) feed(event) + + let snapshot = session.getSnapshot() + expect(snapshot.partial).toBeNull() + expect(snapshot.nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + turn: 1, + step: 0, + retry: 1, + maxRetries: 2, + delayMs: 450, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }) + expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') + + for (const event of retryTurn.slice(7)) feed(event) + snapshot = session.getSnapshot() + expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) + + const replay = makeSession() + replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) + await replay.session.open() + expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) + expect(replay.session.getSnapshot().partial).toBeNull() + }) + + it('ignores malformed retry payloads without retracting the current partial', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.chunkStart(7, 1)) + feed(ev.chunkText(8, 1, '仍在生成')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + feed(at(9, { + type: 'llm/retry', + data: { + turn: 1, step: 0, retry: 3, maxRetries: 2, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'bad budget' }, + }, + })) + expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) + expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') + } finally { + errorSpy.mockRestore() + } + }) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 2e22ea1013..fb75d70312 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0711adcccb..6be40ddb91 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,6 +8,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. +The chat flow projects consecutive model-retry nodes from one turn into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown derives from the scheduled delay, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer, then settles to a static completed label. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. + Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8023acddde..79577d18e8 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -45,6 +45,16 @@ type RenderToolRow = ChatViewSlotProps['renderSlot'] * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook +function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { + if (!running) return null + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const node = nodes[index]! + if (node.kind === 'model-retry') return node.seq + if (node.kind === 'assistant' || node.kind === 'user') return null + } + return null +} + /** One tool call row (result or running): dispatches through the keyed * toolview slot with the owner payload; unregistered tools fall back to * GenericToolCard at this render site. */ @@ -115,6 +125,7 @@ function StreamingTail({ useSession, onGrow }: { /** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession((s) => s.nodes) + const running = useSession((s) => s.running) const runningCalls = useSession((s) => s.runningCalls) const pending = useSession((s) => s.pending) const openState = useSession((s) => s.openState) @@ -124,6 +135,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl const selectedCallId = useStore((s) => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -220,7 +232,13 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return ( + + ) } return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 50e560278d..870f646099 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -32,3 +32,103 @@ .contextRow { padding: 2px 0; } + +.retryRow { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.retrySummary { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 0; + gap: 7px; + border-radius: 3px; + color: inherit; + cursor: pointer; + list-style: none; + user-select: none; +} + +.retrySummary::-webkit-details-marker { + display: none; +} + +.retrySummary::after { + width: 6px; + height: 6px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + content: ''; + opacity: 0.8; + transform: rotate(-45deg); + transition: transform 120ms ease; +} + +.retrySummary:hover { + color: var(--dsw-alias-label-secondary); +} + +.retrySummary:focus-visible { + outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline-offset: 2px; +} + +.retryText { + color: inherit; +} + +.retryRow[data-active] .retryText { + background: + linear-gradient( + 90deg, + var(--dsw-alias-label-tertiary) 0%, + var(--dsw-alias-label-tertiary) 40%, + var(--dsw-alias-label-secondary) 50%, + var(--dsw-alias-label-tertiary) 60%, + var(--dsw-alias-label-tertiary) 100% + ); + background-position: 100% 50%; + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: retry-shimmer 1.6s ease-in-out infinite; +} + +.retryRow[open] .retrySummary::after { + transform: rotate(45deg); +} + +.retryDetails { + display: grid; + gap: 2px; + margin-top: 3px; + padding-left: 14px; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 18px; +} + +.retryDetailLabel { + color: var(--dsw-alias-label-secondary); +} + +@keyframes retry-shimmer { + from { + background-position: 100% 50%; + } + + to { + background-position: 0 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .retryRow[data-active] .retryText { + background: none; + color: inherit; + animation: none; + } +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..c1c4ac5b4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,17 +1,18 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned), -// steering (badged bubble), context injection and unknown-surface JSON rows. +// MessageItem: simple chat nodes — user bubble (right-aligned), steering +// (badged bubble), context injection, retry disclosure and unknown JSON rows. // Props are frozen node slices off the snapshot cache; memo holds across // streaming because unchanged nodes keep their references. -import { memo } from 'react' +import { memo, useEffect, useState } from 'react' import type { - ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, + ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' export interface MessageItemProps { - node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode + retryActive?: boolean } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -25,7 +26,56 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +function retrySeconds(milliseconds: number): number { + return Math.max(1, Math.ceil(milliseconds / 1_000)) +} + +interface RetryCountdown { + deadline: number + seconds: number +} + +function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolean }) { + const deadline = node.time + node.delayMs + const scheduledSeconds = retrySeconds(node.delayMs) + const [countdown, setCountdown] = useState(() => ({ + deadline, + seconds: retrySeconds(deadline - Date.now()), + })) + const remainingSeconds = countdown.deadline === deadline + ? countdown.seconds + : retrySeconds(deadline - Date.now()) + + useEffect(() => { + if (!active || retrySeconds(deadline - Date.now()) === 1) return + const timer = window.setInterval(() => { + const next = retrySeconds(deadline - Date.now()) + setCountdown(current => ( + current.deadline === deadline && current.seconds === next + ? current + : { deadline, seconds: next } + )) + if (next === 1) window.clearInterval(timer) + }, 250) + return () => { window.clearInterval(timer) } + }, [active, deadline]) + + return ( +
+ + + {active ? '正在重试' : '已重试'}模型请求({node.retry}/{node.maxRetries}) · {active ? remainingSeconds : scheduledSeconds}s + + +
+
重试延迟:{Math.round(node.delayMs)}ms
+
失败原因:{node.failure.message}
+
+
+ ) +} + +export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { @@ -46,6 +96,8 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) ) + case 'model-retry': + return default: return (
diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 8f47e334c5..39906c5aa8 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -1,7 +1,8 @@ /** * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool * results group into consecutive-run tool groups (figma step-summary flow, - * VERTICAL gap10) alternating with narration; everything else passes through. + * VERTICAL gap10) alternating with narration. Consecutive retry notices from + * one turn reuse the first notice's row while projecting the latest attempt. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. */ @@ -15,7 +16,7 @@ export type ChatFlowItem = /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). - * @returns flow items; consecutive tool-results merged into one group keyed by the first seq. + * @returns flow items; consecutive tool results and same-turn retry notices reuse their first key. */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] @@ -28,6 +29,18 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } else { group.push(node) } + } else if (node.kind === 'model-retry') { + group = null + const previous = items[items.length - 1] + if ( + previous?.kind === 'node' + && previous.node.kind === 'model-retry' + && previous.node.turn === node.turn + ) { + items[items.length - 1] = { ...previous, node } + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } } else { group = null items.push({ kind: 'node', key: `n${node.seq}`, node }) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..38d901dafc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -5,7 +5,7 @@ // machinery specs since the tool ring dissolved into renderSlot.) import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,7 +15,10 @@ import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) describe('MessageItem arms', () => { it('steering bubbles carry the interjection badge and non-text rest blocks', () => { @@ -41,6 +44,78 @@ describe('MessageItem arms', () => { ) expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy() }) + + it('collapses retry details behind the durable model retry status', () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + const view = render( + , + ) + const details = view.container.querySelector('details') + const summary = view.container.querySelector('summary') + expect(details?.open).toBe(false) + expect(details?.dataset.active).toBe('true') + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s') + expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms') + expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置') + + act(() => { vi.advanceTimersByTime(1_100) }) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s') + act(() => { vi.advanceTimersByTime(1_000) }) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + + view.rerender( + , + ) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s') + + if (summary === null) throw new Error('retry summary missing') + fireEvent.click(summary) + expect(details?.open).toBe(true) + + view.rerender( + , + ) + expect(details?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s') + }) }) describe('small branch tails', () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index bb55fcac77..ba661b276c 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, + AssistantMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -59,6 +59,11 @@ const user = (seq: number, text: string): UserMessageNode => ({ const assistant = (seq: number, text: string): AssistantMessageNode => ({ kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) +const retry = (seq: number): ModelRetryNode => ({ + kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + retry: 1, maxRetries: 2, delayMs: 450, + failure: { code: 'TRANSPORT', message: '连接被重置' }, +}) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, @@ -128,6 +133,17 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) + + it('reuses one stable row for consecutive retries in the same turn', () => { + const first = retry(2) + const second = { ...retry(3), step: 1, retry: 2 } + const initial = deriveChatFlow([user(1, 'try'), first]) + const updated = deriveChatFlow([user(1, 'try'), first, second]) + expect(flowKeys(initial)).toBe('n1|n2') + expect(flowKeys(updated)).toBe('n1|n2') + expect(updated).toHaveLength(2) + expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) + }) }) describe('ChatView', () => { @@ -168,6 +184,31 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('animates only the latest unresolved model retry', () => { + const retryNode = retry(2) + const nextRetry = { ...retry(3), step: 1, retry: 2 } + const context = { + kind: 'context', seq: 4, time: 4_000, content: [], source: null, + } as const satisfies ConversationNode + const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) + const view = render() + const disclosure = view.container.querySelector('details') + expect(disclosure?.dataset.active).toBe('true') + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + + act(() => h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })) + expect(view.getAllByRole('status')).toHaveLength(1) + expect(view.container.querySelector('details')).toBe(disclosure) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') + + act(() => h.set({ nodes: [user(1, 'try'), retryNode, nextRetry, context, assistant(5, 'done')] })) + expect(disclosure?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') + + act(() => h.set({ nodes: [user(1, 'try'), retry(6)], running: false })) + expect(disclosure?.dataset.active).toBeUndefined() + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index da1084ba31..8628e8aa37 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -4,7 +4,7 @@ Function plugin that retries selected transient model-request failures on the ag The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. -Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6d6c27636c..757bf49730 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -15,11 +15,16 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index f37cf47e7e..ecf1be4233 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -26,6 +26,8 @@ declare module '@deepseek-ai/dsh-session' { } } +export type { LlmRetryEventData } from './types.ts' + export const name = 'llm-retry' export const inject = ['agents'] diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts new file mode 100644 index 0000000000..5e3fb0322f --- /dev/null +++ b/packages/llm/llm-retry/src/types.ts @@ -0,0 +1,11 @@ +import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' + +/** Durable payload recorded before one transient model-request retry wait. */ +export interface LlmRetryEventData { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmFailure +} diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 284a3686dc..5ba0361cc4 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -15,6 +16,10 @@ import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable +it('keeps the browser-safe retry payload identical to the session event', () => { + expectTypeOf().toEqualTypeOf() +}) + class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..bcb6c0402b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths @@ -782,6 +785,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..1846e55366 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], + "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], From 891e9035e7e792a8c13bb91fd3f4fab342b87600 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:15:06 +0800 Subject: [PATCH 002/709] feat(web): add basic past-session search (round 1) --- .../2026-07-27-web-session-search.i18n.yaml | 6 + .../feature/2026-07-27-web-session-search.md | 42 ++++ .../2026-07-27-web-session-search.zh.md | 42 ++++ apps/cli/README.i18n.yaml | 6 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 7 + apps/cli/package.json | 2 + apps/web/tests/navigation-panes.e2e.ts | 62 ++--- apps/web/tests/scaffold.ts | 1 + .../search-results.expected.md | 2 + packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 126 +++++++++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 35 +++ packages/client/runtime/README.i18n.yaml | 6 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 29 ++- .../runtime/src/client/sessions/service.ts | 20 +- packages/client/runtime/tests/fake-api.ts | 9 +- packages/client/runtime/tests/manager.spec.ts | 43 ++++ .../runtime/tests/sessions-service.spec.ts | 23 ++ packages/client/ui-workspace/README.i18n.yaml | 6 +- packages/client/ui-workspace/README.md | 7 +- packages/client/ui-workspace/README.zh.md | 7 +- .../src/client/WorkspaceBrowser.module.css | 16 ++ .../src/client/WorkspaceBrowser.tsx | 159 ++++++++++-- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 6 + .../src/client/rows/Rows.module.css | 58 +++++ .../ui-workspace/src/client/rows/Rows.tsx | 37 ++- .../client/ui-workspace/src/client/tree.ts | 184 ++++++++------ .../client/ui-workspace/tests/apply.spec.ts | 37 ++- .../client/ui-workspace/tests/rows.spec.tsx | 23 +- .../client/ui-workspace/tests/tree.spec.ts | 172 ++++++++----- .../tests/workspace-browser.spec.tsx | 201 ++++++++++++--- packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 96 ++++++- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 25 +- packages/host/apiproxy/src/api/sessions.ts | 17 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 5 +- .../apiproxy/tests/api-proxy-search.spec.ts | 237 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 25 ++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 47 ++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 24 +- packages/host/apiproxy/tsconfig.json | 3 + pnpm-lock.yaml | 9 + 56 files changed, 1646 insertions(+), 269 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md create mode 100644 apps/web/tests/snapshots/navigation-panes/search-results.expected.md create mode 100644 packages/host/apiproxy/tests/api-proxy-search.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml new file mode 100644 index 0000000000..31d6b377af --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md +2026-07-27-web-session-search.md: 3cc44ba3652415e9fa32ce67bceefc822c41059b +2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md new file mode 100644 index 0000000000..3cc44ba365 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -0,0 +1,42 @@ +# Agent Note: Web past-session search + +Status: implemented + +English | [中文](2026-07-27-web-session-search.zh.md) + +## Problem + +The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally. + +## Decision + +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. + +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. + +Content matching inherits the SQLite backend's normalized literal token/phrase semantics. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. + +## Failure and visibility contract + +Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. + +While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint. + +## Alternatives considered + +- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`. +- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision. +- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work. +- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded. + +## Consequences + +Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. + +The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. + +## Testing + +Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md new file mode 100644 index 0000000000..2b6ea7a60e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Web 历史会话搜索 + +Status: implemented + +[English](2026-07-27-web-session-search.md) | 中文 + +## 问题 + +Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。 + +## 决策 + +Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 + +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 + +内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 + +## 故障与可见性契约 + +搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 + +首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。 + +## 曾考虑的替代方案 + +- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。 +- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。 +- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。 +- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。 + +## 后果 + +无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 + +首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。 + +## 测试 + +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..e78ab7b8ad 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: bb3f4ee98700e4644535d1d3c05d29a9a558275d +README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6 diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..bb3f4ee987 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..44edea0f5b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 SQLite 内容索引。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..4adb5048b8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,6 +89,13 @@ config: root: './.sessions' +# Lazy content index for session.search. Opening the database at boot does +# not scan logs; the first search reconciles changed live/persisted sessions. +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: './.sessions/session-query.db' + - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..2bc160eda8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,6 +55,8 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..7744ad5c55 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -26,6 +26,7 @@ const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') +const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -90,39 +91,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read']) }, 400_000) - it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open')) - // Expand the collapsed group row, then open the revealed session row. - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() + it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + const search = page.getByPlaceholder('搜索名称或关键词', { exact: false }) + // The cold row has not been opened, so only the persisted log can satisfy + // this query. First search lazily reconciles the SQLite content index. + await search.fill('zzzqx-no-such-session') + await page.getByText('没有匹配结果').waitFor({ timeout: 30_000 }) + await expect.poll( + () => page.getByRole('tree', { name: '搜索结果' }).getByRole('treeitem').count(), + { timeout: 10_000 }, + ).toBe(0) + + await search.fill('WATERFALL') + const resultTree = page.getByRole('tree', { name: '搜索结果' }) + const result = resultTree.getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { + timeout: 10_000, + }).toBeGreaterThanOrEqual(1) + const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE) + + await result.click() + // Search navigation addresses the session, not a specific event, and the + // query remains until the user explicitly clears it. + await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) - }, 90_000) - - it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) - // Runs after the session is open: a cold summary carries no title (the - // sidebar shows the cwd basename), and the durable title lands with the - // attach subscription's baseline — which is itself worth pinning: search - // matches the title the user sees, not a hidden cold field. - const search = page.getByPlaceholder('Search name, keywords', { exact: false }) - await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // Negative: a garbage query empties the tree (group rows hide too). - await search.fill('zzzqx-no-such-session') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0) - // Positive: a title word narrows to the matched session + its group, - // force-expanded by search mode (case-insensitive client-side filter). - await search.fill('navscenario') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) - // Clear restores the unfiltered tree. - await page.getByRole('button', { name: 'Clear search' }).click() + await page.getByRole('button', { name: '清除搜索' }).click() await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - }, 60_000) + }, 90_000) it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) @@ -184,7 +185,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md', + 'waterfall.expected.md', 'details-open.expected.md', ]) }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 34d0e5f123..f809081d3d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,6 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0 } } +/** Fixture mirror of first-party message extraction used by session-query. */ +function searchBlockText(block: ContentBlock): string[] { + switch (block.type) { + case 'text': + case 'reasoning': + return [block.text] + 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 { + if ( + event.type !== 'user/message' + && event.type !== 'assistant/message' + && event.type !== 'steering/message' + ) return '' + return event.data.content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n') +} + +/** + * 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 searchTokens(value: string): string[] { + return value + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .toLowerCase() + .match(/[\p{L}\p{N}\p{Co}]+/gu) ?? [] +} + +/** Count exact contiguous token-phrase occurrences in one fixture document. */ +function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number { + if (phrase.length === 0 || phrase.length > document.length) return 0 + let count = 0 + for (let start = 0; start <= document.length - phrase.length; start++) { + if (phrase.every((token, offset) => document[start + offset] === token)) count++ + } + return count +} + +/** One-line fixture excerpt, bounded so the sidebar remains readable. */ +function searchSnippet(value: string): string { + const oneLine = value.replace(/\s+/gu, ' ').trim() + return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}…` +} + +interface FixtureSearchCandidate { + sessionId: SessionId + seq: number + time: number + text: string + matchCount: number + documentLength: number +} + +/** Same rank keys as session-query-sqlite's cross-session result order. */ +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 +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -547,6 +620,42 @@ 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 = searchTokens(request.payload.query) + 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 matchCount = phraseMatchCount(searchTokens(eventText), query) + if (matchCount === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: eventText, + matchCount, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return ok(request, { + items: matches.slice(0, 20).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text), + })), + hasMore: matches.length > 20, + }) + }, create: async (request) => { const workspace = request.payload.workspaceId === undefined ? undefined @@ -892,20 +1001,30 @@ export class FixtureApiClient extends AbstractApiClient { protected override async callUnary( method: K, payload: RequestPayload, + signal?: AbortSignal, ): Promise>> { 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) as RpcResponse> + const response = await this.dispatch( + method, + request as RpcRequest, + signal ?? new AbortController().signal, + ) as RpcResponse> 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): Promise> { + private dispatch( + method: keyof RpcMethodMap, + request: RpcRequest, + signal: AbortSignal, + ): Promise> { 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.prompt': return this.api.sessions.prompt(request) @@ -916,8 +1035,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.rename': return this.api.workspace.rename(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) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..8dd7b9b1e9 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..d7785697e8 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcRequest, RpcResponse, SessionId, SkillEntry, + RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -43,6 +43,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = @@ -55,12 +57,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + 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)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0374f92b3b..9bf0173237 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -48,6 +48,37 @@ 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 历史消息') + + 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 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 })) @@ -601,6 +632,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 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5e73979b0b..2e854cc6dd 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98 -README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: c83e70d574b85ff1128f48725b03811411b62fe2 +README.zh.md: ab97832760bb7f147211cf430aa5c7601b4dcbd8 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4724ebc75d..c83e70d574 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,6 +10,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. + ## 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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a0076742e..ab97832760 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。 + ## 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` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..620ccb885d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -18,7 +18,7 @@ export type { Session } from './sessions/session.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 { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 694768ebbc..93a9b8e597 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -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' @@ -22,6 +25,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[] @@ -213,6 +222,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> { + 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 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..9f239a47dd 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -16,7 +16,9 @@ * 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' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' @@ -24,7 +26,7 @@ import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.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 type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ @@ -318,6 +320,20 @@ export class SessionsService { 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> { + return this.manager.search(query, signal) + } + /** * Route a mux stream envelope into the Session object layer. * @param envelope - validated mux stream envelope. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..504b9431aa 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -60,6 +60,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = @@ -72,12 +74,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + 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)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index ee76d885ab..5d42aa685f 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -194,6 +194,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() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..0d19b914b7 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -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() diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d0f2d0a20a..f89fbee5d4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 -README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 +# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md +README.md: 9cb919a1a64394d5e116d35bdddfdee738994a02 +README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e0247b3e26..9cb919a1a6 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. +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 modals. + +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. 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; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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. @@ -18,5 +20,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **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 Workspace delete control** — the browser supports creation and rename, while the picker supports selection and creation. - **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92ef463faa..b3add7f89c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 +共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。 + +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 @@ -18,5 +20,6 @@ ## 已知限制与暂缓事项 -- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 +- **没有 Workspace 删除控件**:浏览器支持创建和重命名,选择器支持选择和创建。 - **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index d6375cb698..96af22a309 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -209,6 +209,22 @@ padding-bottom: 12px; } +.list > [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 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0dc6485929..d703674c01 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -13,16 +13,20 @@ 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' /** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — 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 const GROUP_BY_ITEMS = [ { type: 'label' as const, id: 'group-by', text: 'Group by' }, @@ -83,14 +87,12 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' > & { 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 } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { +function SessionTree({ useSessions, startSession, open, workspaces, onRenameRequest, insertSessionBefore }: SessionTreeProps) { const list = useSessions((s) => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) @@ -106,8 +108,8 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions }), + [list, workspaces, expandedProjects, expandedSessions], ) const now = Date.now() @@ -115,7 +117,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
{groups.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
+
No sessions yet
)} {groups.map(group => ( // Group section: header row + expanded session subtree. The @@ -136,10 +138,10 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen }} /> {group.sessions.map((node, index) => { - // Draggable: real-workspace group roots outside search. The drag + // Draggable: real-workspace group roots. 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: () => { @@ -192,15 +194,15 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, query }: Pick) { +function FlatList({ useSessions, open }: Pick) { const list = useSessions((s) => s) - const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const rows = useMemo(() => deriveFlat(list), [list]) const now = Date.now() return (
{rows.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
+
No sessions yet
)} {rows.map(node => ( & { + workspaces: readonly WorkspaceView[] + query: string + remote: RemoteSearchState +}) { + const list = useSessions((s) => s) + const currentRemote = remote.query === query + ? remote + : { query, status: 'loading' as const, items: [], hasMore: false } + const results = useMemo( + () => deriveSearchResults(list, workspaces, query, currentRemote), + [list, workspaces, query, currentRemote], + ) + const pending = currentRemote.status === 'loading' + const failed = currentRemote.status === 'error' + + return ( +
+
+ {results.items.map(result => ( + + ))} + {pending && ( +
正在搜索历史…
+ )} + {failed && ( +
+ 历史内容搜索暂时不可用,仍显示名称匹配。 +
+ )} + {!pending && results.items.length === 0 && ( +
没有匹配结果
+ )} + {results.hasMore && ( +
仅显示前 20 项,请缩小搜索范围。
+ )} +
+ +
+ ) +} + /** * Render the browsing region. * @param props - composed slot props (shell owner share + store + injected actions). @@ -238,12 +301,20 @@ export function WorkspaceBrowser({ renameWorkspace, insertSessionBefore, createWorkspace, + searchSessions, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) // 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 = query.trim() + const [remoteSearch, setRemoteSearch] = useState({ + query: '', + status: 'idle', + items: [], + hasMore: false, + }) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -263,6 +334,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('') @@ -331,11 +439,11 @@ export function WorkspaceBrowser({ {/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Rail: the icon is the region's search control. */}
{ if (wide) searchInput.current?.focus() }}> - + + ) +} + /** 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() diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index c0adfadd6f..76dcfe924e 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -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 = '' @@ -15,7 +17,7 @@ export const UNGROUPED_LABEL = 'Ungrouped' export interface SessionNode { id: SessionId title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ + /** Visible children, already expansion-filtered (empty when folded). */ children: readonly SessionNode[] /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean @@ -41,11 +43,25 @@ 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 — the component's local useState arrays, taken as-is. */ export interface TreeView { expandedProjects: readonly string[] expandedSessions: readonly string[] - query: string } interface Group { @@ -204,47 +220,15 @@ function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionN return g.roots.map(walk).filter((n): n is SessionNode => n !== null) } -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!sessionTitle(m).toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - /** * Derive the nested workspace browser group structure. * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. + * Every group shows; sessions populate under expanded groups, descending + * only into expanded sessions. Blank sessions are excluded except for the + * selected provisional New Session row. * @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( @@ -252,7 +236,6 @@ export function deriveGroups( workspaces: readonly WorkspaceView[], view: TreeView, ): GroupNode[] { - const q = view.query.trim().toLowerCase() const expandedProjects = new Set(view.expandedProjects) const expandedSessions = new Set(view.expandedSessions) const currentGroup = list.current === undefined @@ -261,32 +244,17 @@ 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, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } + const expanded = expandedProjects.has(g.key) + groups.push({ + key: g.key, + workspaceId: g.workspaceId, + cwd: g.cwd, + label: g.label, + sessionCount: g.summaries.size, + expanded, + containsCurrent: g.key === currentGroup, + sessions: expanded ? buildVisible(g, expandedSessions) : [], + }) } return groups } @@ -295,25 +263,97 @@ 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; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. + * empty so the renderer stays branch-free. * @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): 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 !== '' && !sessionTitle(s).toLowerCase().includes(q)) continue rows.push(s) } rows.sort(byRecency) return rows.map(s => sessionNode(s, [], false, false)) } +/** Maximum rows rendered by the basic search surface. */ +const SEARCH_RESULT_LIMIT = 20 + +/** + * Merge immediate title/Workspace substring matches with ranked Host content + * matches. Local rows lead newest-first, content-only rows retain backend + * 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. + * @returns at most 20 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 }, +): SearchResultSet { + const q = query.trim().toLowerCase() + if (q === '') return { items: [], hasMore: false } + + const workspaceBySession = new Map() + 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() + 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] + if (summary === undefined || !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() + 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 && sessionVisible(summary, list.current)) include(summary) + } + + return { + items: ordered.slice(0, SEARCH_RESULT_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 > SEARCH_RESULT_LIMIT, + } +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 8961ccdb83..b2a70d0f0b 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -19,11 +19,25 @@ 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 }, + })) ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore, } as never) - ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } + ctx.provide('sessions', { open, clear, search } as never) + return { + ctx, + slots: ctx.get('slots') as SlotsService, + create, + startSession, + rename, + insertSessionBefore, + open, + clear, + search, + } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -66,6 +80,12 @@ 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) await browser.renameWorkspace('ws' as never, 'renamed') expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) @@ -78,6 +98,19 @@ describe('ui-workspace apply', () => { expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) + 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') diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 70cfb36940..fc22398cb0 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' 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' afterEach(cleanup) @@ -38,6 +38,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() + 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() diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 4af5d5f70c..e308c79eec 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -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, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { + deriveFlat, deriveGroups, deriveSearchResults, formatRelativeTime, projectLabel, + 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, expandedSessions: [] as string[], query, +const view = (expandedProjects: readonly string[] = []) => ({ + expandedProjects, expandedSessions: [] as string[], }) describe('deriveGroups', () => { @@ -59,21 +62,6 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) - it('searches the current blank session by its New Session title', () => { - 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, - } - const groups = deriveGroups( - sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'), - ) - expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id]) - expect(groups[0]!.sessions[0]!.title).toBe('New Session') - expect(groups[0]!.sessionCount).toBe(1) - }) - it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => { const parent = summary('parent', 1) const oldChild = { ...summary('old-child', 10), parentId: parent.id } @@ -87,7 +75,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], - { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' }, + { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id] }, ) expect(groups).toHaveLength(1) @@ -113,31 +101,6 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) - it('searches descendants with ancestors and handles cycles, self parents, and 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.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([ - root.id, 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) @@ -155,21 +118,15 @@ 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')]) // Rows are branch-free: no children, no expansion. expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) }) - 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 with its New Session title', () => { @@ -179,11 +136,112 @@ 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(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id]) - 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'), + ], + ' NEEDLE ', + { + items: [ + { sessionId: contentHit.id, snippet: 'body needle excerpt' }, + { sessionId: titleHit.id, snippet: 'title session body excerpt' }, + { sessionId: sid('unknown'), snippet: 'not in session.list' }, + ], + hasMore: false, + }, + ) + + 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('shows only the current blank row and uses its New Session display title', () => { + 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, + } + 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, + }, + ) + expect(result.items).toEqual([{ + id: currentBlank.id, + title: 'New Session', + workspace: 'first', + running: false, + snippet: 'current body', + }]) + }) + + it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => { + const rows = Array.from({ length: 22 }, (_, index) => { + const item = summary(`s-${String(index).padStart(2, '0')}`, index) + item.displayTitle = `Needle ${String(index)}` + return item + }) + const overflow = deriveSearchResults(list(...rows), [], 'needle', { items: [], hasMore: false }) + expect(overflow.items).toHaveLength(20) + expect(overflow.hasMore).toBe(true) + + const backendMore = deriveSearchResults( + list(summary('body', 1)), + [], + 'needle', + { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, + ) + expect(backendMore.items).toHaveLength(1) + expect(backendMore.hasMore).toBe(true) + expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true })) + .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e9b55e7b76..bce6fe6765 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -53,6 +53,7 @@ function mount(overrides: Partial = {}) { actions: store.actions, startSession: vi.fn(), open: vi.fn(), + searchSessions: vi.fn(async () => ({ items: [], hasMore: false })), renameWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), @@ -198,42 +199,168 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getAllByText('New Session')).toHaveLength(1) - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'new session' } }) expect(screen.getAllByText('New Session')).toHaveLength(1) }) - 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('Search name, keywords...') - 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('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - 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('搜索名称或关键词…') + fireEvent.change(input, { target: { value: 'needle' } }) + expect(screen.getByRole('tree', { name: '搜索结果' })).toBeTruthy() + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + expect(screen.getByText('正在搜索历史…')).toBeTruthy() + + 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: 'Sessions' })).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('No sessions yet')).toBeTruthy() - b.store.actions.setGroupBy('flat') - rerender(b, {}) - expect(screen.getByText('No sessions yet')).toBeTruthy() - // Flat search misses show No matches. - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) - expect(screen.getByText('No matches')).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('搜索名称或关键词…') + 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('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('shows the no-sessions empty state in both modes and resolves an empty search', async () => { + vi.useFakeTimers() + try { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).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', () => { @@ -243,16 +370,16 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('Workspaces')).toBeNull() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(screen.queryByPlaceholderText('搜索名称或关键词…')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('Search name, keywords...') + const input = screen.getByPlaceholderText('搜索名称或关键词…') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -463,8 +590,8 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + 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) }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..7d323dbeea 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: deb1073c2ff9e7a533e595ee3f5537e649660e5b +README.zh.md: e3c521d5f6a09414d087e3fb142852e6d3eb0cb7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..deb1073c2f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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 passes only those session ids and current-surface user, assistant, and steering messages to the optional `ctx.sessionQuery` service, returns at most 20 session/snippet pairs plus a refine-query bit, and forwards the carrier request signal for cancellation. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. + The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..e3c521d5f6 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关仅将这些会话 id 以及当前表层中的 user、assistant 和 steering(中途引导)消息传给可选的 `ctx.sessionQuery` 服务,返回至多 20 个会话/snippet 对和一个提示细化查询的标志位,并转发载体请求信号以支持取消。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 + `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0c7107a1f9..c84a4c00ff 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..a5055ef1d8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,6 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -20,8 +21,8 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, - WorkspaceId, WorkspaceView, + ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem, + SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' @@ -37,9 +38,17 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Product contract: sidebar search returns one bounded page and no cursor. */ +const SESSION_SEARCH_LIMIT = 20 + /** Surface 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 surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -561,6 +570,26 @@ 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(): Promise { + const items = ctx.sessions.list().map((session) => { + const agent = ctx.agents.get(session.id) + return summarize(session, agent?.status === 'running') + }) + 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(meta => summarizeCold(persistence, meta)))) + } + items.sort((a, b) => b.updatedAt - a.updatedAt) + return items + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -568,18 +597,61 @@ 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) - return summarize(session, agent?.status === 'running') + 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(meta => summarizeCold(persistence, meta)))) + 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() + 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 page = await sessionQuery.searchSessions({ + query: request.payload.query, + sessionFilters: [{ kind: 'id', values: [...visibleIds] }], + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: SESSION_SEARCH_LIMIT, + }, { signal }) + if (isAborted(signal)) return cancelled() + // The id filter is the authorization boundary. Re-check the provider + // projection before emitting it so a backend regression cannot leak + // a session that `session.list` withheld. + const authorized = page.items.filter(hit => visibleIds.has(hit.header.id)) + return ok(request, { + items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ + sessionId: hit.header.id, + snippet: hit.bestMatch.snippet, + })), + hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT, + }) + } catch (error: unknown) { + if ( + isAborted(signal) + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') + ) return cancelled() + 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) { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 537b2744ef..4425e05ecf 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -25,7 +25,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, SessionSearchItem, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..8f136de494 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -18,6 +18,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.prompt': SessionsApi['prompt'] diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 12ebd4182d..4db9ac32c4 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { HistoryEntry, SessionSummary } from './sessions.ts' +import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -54,6 +54,29 @@ export const sessionListValueSchema = z.object({ items: z.array(sessionSummarySchema), }) satisfies z.ZodType>> +/** Fixed wire bound for one interactive sidebar query. */ +const SESSION_SEARCH_QUERY_MAX_CHARS = 500 +/** Product response bound validated independently by every client carrier. */ +const SESSION_SEARCH_RESULT_LIMIT = 20 + +/** 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>> + +/** One session.search result. */ +export const sessionSearchItemSchema = z.object({ + sessionId: sessionIdSchema, + snippet: z.string(), +}) satisfies z.ZodType> + +/** session.search response value. */ +export const sessionSearchValueSchema = z.object({ + items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT), + hasMore: z.boolean(), +}) satisfies z.ZodType>> + /** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2552b5d5a3..7f62eea93f 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -53,11 +53,28 @@ export interface SessionSummary { cwd?: string } +/** 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> + /** + * 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> + /** * 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 diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..b967e5b80c 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,6 +20,7 @@ import { sessionHistoryValueSchema, sessionListValueSchema, sessionPromptValueSchema, + sessionSearchValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -48,6 +49,7 @@ import { skillListValueSchema } from '../api/skills.schema.ts' export interface IApiClient { sessions: { list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise>> + search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> @@ -83,6 +85,7 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'session.list': sessionListValueSchema, + 'session.search': sessionSearchValueSchema, 'session.create': sessionCreateValueSchema, 'session.history': sessionHistoryValueSchema, 'session.prompt': sessionPromptValueSchema, @@ -271,6 +274,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), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..4115f77fa0 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -20,6 +20,7 @@ import { sessionHistoryRequestSchema, sessionListRequestSchema, sessionPromptRequestSchema, + sessionSearchRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { @@ -38,7 +39,8 @@ import { skillListRequestSchema } from '../api/skills.schema.ts' * 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]: { @@ -49,6 +51,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.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts new file mode 100644 index 0000000000..5f8060a696 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -0,0 +1,237 @@ +/** + * Host session.search projection: list-equivalent visibility, fixed message + * filters and result bound, cancellation mapping, and unavailable/failure + * behavior. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { + SessionQueryError, + type SessionSearchHit, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (value: string): SessionId => value as SessionId +const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } + +function request(query: string): RpcRequest<{ query: string }> { + return { rpcId: RpcId(`search-${query}`), payload: { query } } +} + +function header(id: string, cwd: string | null = '/project'): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 100, + ...(cwd === null ? {} : { cwd }), + } +} + +function hit(id: string, index = 0): SessionSearchHit { + const session = header(id) + return { + header: session, + live: true, + persisted: false, + bestMatch: { + sessionId: session.id, + seq: index, + type: 'user/message', + time: 200 + index, + surface: 'current', + snippet: `match ${index}`, + }, + } +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + return ctx +} + +describe('session.search', () => { + it('searches only list-visible ids and current conversation-message events', async () => { + const ctx = await baseContext() + const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') }) + live.append('user/message', { + content: [{ type: 'text', text: 'live text' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const cold = header('cold', '/cold') + const legacy = header('legacy', null) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([cold, legacy]), + locate: () => undefined, + } as never) + + const searchSessions = vi.fn(( + _request: SessionSearchRequest, + _exec?: { signal?: AbortSignal }, + ) => Promise.resolve({ + items: [ + { + header: legacy, + live: false, + persisted: true, + bestMatch: { + sessionId: legacy.id, + seq: 3, + type: 'user/message' as const, + time: 190, + surface: 'current' as const, + snippet: 'must remain hidden', + }, + }, + { + header: cold, + live: false, + persisted: true, + bestMatch: { + sessionId: cold.id, + seq: 4, + type: 'assistant/message' as const, + time: 200, + surface: 'current' as const, + snippet: 'the matching answer', + }, + }, + ], + nextCursor: 'more' as never, + })) + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + const signal = new AbortController().signal + + const response = await api.sessions.search(request('matching answer'), signal) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'cold', snippet: 'the matching answer' }], + hasMore: true, + }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + const [query, exec] = searchSessions.mock.calls[0] as unknown as [ + SessionSearchRequest, + { signal: AbortSignal }, + ] + expect(query).toEqual({ + query: 'matching answer', + sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }], + eventFilters: [ + { + kind: 'type', + values: ['user/message', 'assistant/message', 'steering/message'], + }, + { kind: 'surface', values: ['current'] }, + ], + limit: 20, + }) + expect(exec.signal).toBe(signal) + }) + + it('returns an empty page without invoking the index when no session is visible', async () => { + const ctx = await baseContext() + const searchSessions = vi.fn() + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + + const response = await api.sessions.search( + request('anything'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + expect(searchSessions).not.toHaveBeenCalled() + }) + + it('enforces the 20-item Host boundary even if a provider overproduces', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ items }), + } as never) + const response = await createApiProxy(ctx, defaults).sessions.search( + request('match'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.items).toHaveLength(20) + expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') + }) + + it('maps missing composition, query cancellation, and provider failure', async () => { + const missingCtx = await baseContext() + missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) + const missingApi = createApiProxy(missingCtx, defaults) + const preAborted = new AbortController() + preAborted.abort() + const cancelledBeforeLookup = await missingApi.sessions.search( + request('cancel-before-lookup'), + preAborted.signal, + ) + expect(cancelledBeforeLookup.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const missing = await missingApi.sessions.search( + request('needle'), + new AbortController().signal, + ) + expect(missing.result.ok).toBe(false) + if (missing.result.ok) throw new Error('unreachable') + expect(missing.result.error.code).toBe('internal') + expect(missing.result.error.message).toContain('does not mount') + + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED') + const searchSessions = vi.fn() + .mockRejectedValueOnce(aborted) + .mockRejectedValueOnce(new Error('database unavailable')) + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + + const cancelled = await api.sessions.search( + request('first'), + new AbortController().signal, + ) + expect(cancelled.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const failed = await api.sessions.search( + request('second'), + new AbortController().signal, + ) + expect(failed.result.ok).toBe(false) + if (failed.result.ok) throw new Error('unreachable') + expect(failed.result.error.code).toBe('internal') + expect(failed.result.error.message).toContain('database unavailable') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..fe583b7351 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -29,6 +29,7 @@ function scriptedApi(overrides: { return { sessions: { list: r => ok(r, { items: [] }), + search: r => ok(r, { items: [], hasMore: false }), create: r => ok(r, { sessionId: sid('s-new') }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), @@ -76,6 +77,30 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) + it('round-trips a trimmed session search query and its bounded result metadata', async () => { + let seen: RpcRequest<{ query: string }> | undefined + const api = scriptedApi({ + sessions: { + search: (request) => { + seen = request + return ok(request, { + items: [{ sessionId: sid('s1'), snippet: 'matching message text' }], + hasMore: true, + }) + }, + }, + }) + const response = await client(api).sessions.search({ query: ' message text ' }) + expect(seen?.payload).toEqual({ query: 'message text' }) + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 's1', snippet: 'matching message text' }], + hasMore: true, + }, + }) + }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..1d9bd00122 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -21,6 +21,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra if (overrides.crashOn === 'session.list') throw new Error('impl crashed') return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } }, + async search(request, signal) { + if (request.payload.query === 'hang') { + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, + } + } + return { + rpcId: request.rpcId, + result: { + ok: true, + value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false }, + }, + } + }, async create(request) { return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } }, @@ -124,6 +144,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { it('covers create/prompt/cancel/describe passthrough', async () => { const c = client() + expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({ + ok: true, + value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false }, + }) expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) @@ -155,6 +179,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(parsed.rpcId).toBe('r-sig') expect(parsed.result.error?.code).toBe('cancelled') }) + + it('propagates the carrier Request signal into session.search', async () => { + const handler = toFetchHandler(fakeApi()) + const controller = new AbortController() + const body = JSON.stringify({ + type: 'client-request', + rpcId: 'r-search-sig', + method: 'session.search', + payload: { query: 'hang' }, + }) + const pending = handler.fetch(new Request( + 'http://x/api/session.search', + { method: 'POST', body, signal: controller.signal }, + )) + controller.abort() + const response = await pending + const parsed = await response.json() as { + rpcId: string + result: { error?: { code: string } } + } + expect(parsed.rpcId).toBe('r-search-sig') + expect(parsed.result.error?.code).toBe('cancelled') + }) }) describe('handler carrier-layer statuses', () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..1261959260 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -9,7 +9,7 @@ import { contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, - sessionPromptValueSchema, sessionSummarySchema, + sessionPromptValueSchema, sessionSearchRequestSchema, sessionSearchValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { @@ -121,6 +121,28 @@ describe('sessions domain schemas', () => { expect(sessionListRequestSchema.parse({})).toEqual({}) expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c') expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([]) + expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' }) + expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow() + expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/) + expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow() + expect(sessionSearchValueSchema.parse({ + items: [{ sessionId: 's1', snippet: 'matching text' }], + hasMore: true, + })).toEqual({ + items: [{ sessionId: 's1', snippet: 'matching text' }], + hasMore: true, + }) + expect(() => sessionSearchValueSchema.parse({ + items: [{ sessionId: '', snippet: 'matching text' }], + hasMore: false, + })).toThrow() + expect(() => sessionSearchValueSchema.parse({ + items: Array.from( + { length: 21 }, + (_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }), + ), + hasMore: true, + })).toThrow() expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w') // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects. expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1') diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index f5aabb1cf8..6327c74f5e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../session-title/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..80dfdc102a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,12 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../packages/session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -2559,6 +2565,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title From 222096e3cf50ea1fbe182f129caa96e942fa540d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:27:11 +0800 Subject: [PATCH 003/709] fix(web): validate search hit provenance (round 2) --- .../lifecycle-chrome/hero.expected.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 12 ++++-- .../apiproxy/tests/api-proxy-search.spec.ts | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index f280e35fc6..39fc49c023 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -8,9 +8,9 @@ - img - button "Create workspace": - img -- button "Search sessions": +- button "搜索会话": - img -- textbox "Search name, keywords..." +- textbox "搜索名称或关键词…" - tree "Sessions": - treeitem "workspace 1 session" [expanded]: - img diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a5055ef1d8..d6456acc67 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -630,10 +630,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro limit: SESSION_SEARCH_LIMIT, }, { signal }) if (isAborted(signal)) return cancelled() - // The id filter is the authorization boundary. Re-check the provider - // projection before emitting it so a backend regression cannot leak - // a session that `session.list` withheld. - const authorized = page.items.filter(hit => visibleIds.has(hit.header.id)) + // The filters are the authorization boundary. Re-check the complete + // provider provenance before emitting its snippet so a backend + // regression cannot pair an allowed header with excluded content. + const authorized = page.items.filter(hit => + visibleIds.has(hit.header.id) + && hit.bestMatch.sessionId === hit.header.id + && hit.bestMatch.surface === 'current' + && MESSAGE_TYPES.has(hit.bestMatch.type)) return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ sessionId: hit.header.id, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 5f8060a696..fcb29fdbcc 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -160,6 +160,43 @@ describe('session.search', () => { 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 => { + 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' }), + ], + nextCursor: 'more', + }), + } 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: true, + }, + }) + }) + it('enforces the 20-item Host boundary even if a provider overproduces', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) From 31215687f9e0a8eb390c1be26cb738e3555d01ef Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:42:46 +0800 Subject: [PATCH 004/709] fix(web): honor search ownership and cancellation --- .../2026-07-27-web-session-search.i18n.yaml | 4 +-- .../feature/2026-07-27-web-session-search.md | 4 +-- .../2026-07-27-web-session-search.zh.md | 4 +-- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 8 +++-- packages/host/apiproxy/src/api-proxy.ts | 31 ++++++++++++++--- .../apiproxy/tests/api-proxy-search.spec.ts | 34 +++++++++++++++++++ 9 files changed, 75 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 31d6b377af..1aa4dd3109 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 3cc44ba3652415e9fa32ce67bceefc822c41059b -2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57 +2026-07-27-web-session-search.md: 421922d25bc61c1813a515e8439af0c7956b2efb +2026-07-27-web-session-search.zh.md: 04035e74fae718dbf65b294b4293d368ecc89cf7 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 3cc44ba365..421922d25b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -10,9 +10,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at a process-owned `.sessions/session-query-.db` path. Process scoping preserves the SQLite backend's single-owner contract when multiple CLI or Web processes run from the same directory. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 2b6ea7a60e..04035e74fa 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 +Web 与 headless 共用的组合会在由单一进程拥有的 `.sessions/session-query-.db` 路径挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。当多个 CLI 或 Web 进程从同一目录运行时,进程级隔离可维持 SQLite 后端的单一所有者契约。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index e78ab7b8ad..5e6fb13cd9 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: bb3f4ee98700e4644535d1d3c05d29a9a558275d -README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6 +README.md: c1d76e42a6788f48c4dd01bbf71281e1081a411c +README.zh.md: 69bd88ca8fc7086c94017ac7d25ebd55e8585427 diff --git a/apps/cli/README.md b/apps/cli/README.md index bb3f4ee987..c1d76e42a6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable, process-owned SQLite content index at `.sessions/session-query-.db`. The process-specific path preserves the backend's single-owner contract across parallel invocations. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 44edea0f5b..69bd88ca8f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 SQLite 内容索引。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query-.db` 挂载一个可丢弃、由单一进程拥有的 SQLite 内容索引。该进程专属路径可在并行调用时维持后端的单一所有者契约。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 4adb5048b8..be9303344d 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,12 +89,14 @@ config: root: './.sessions' -# Lazy content index for session.search. Opening the database at boot does -# not scan logs; the first search reconciles changed live/persisted sessions. +# Lazy, process-owned content index for session.search. Opening the database +# at boot does not scan logs; the first search reconciles changed +# live/persisted sessions. The pid prevents concurrent dsh processes in the +# same cwd from sharing one unsupported SQLite owner path. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: - path: './.sessions/session-query.db' + path: !!js "'./.sessions/session-query-' + process.pid + '.db'" - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d6456acc67..367479e698 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 +/** Bound cold-log stat fan-out so an aborted search stops launching new work. */ +const COLD_SUMMARY_BATCH_SIZE = 16 + /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) @@ -170,15 +173,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 { +async function summarizeCold( + persistence: SessionPersistence, + meta: SessionHeader, + signal?: AbortSignal, +): Promise { + 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, @@ -575,16 +585,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * Attached sessions come from memory; servable cold sessions merge from * persistence, and the final order is newest-first. */ - async function listVisibleSessionSummaries(): Promise { + async function listVisibleSessionSummaries(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) return summarize(session, agent?.status === 'running') }) + signal?.throwIfAborted() 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(meta => summarizeCold(persistence, meta)))) + 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) + items.push(...await Promise.all( + batch.map(meta => summarizeCold(persistence, meta, signal)), + )) + signal?.throwIfAborted() + } } items.sort((a, b) => b.updatedAt - a.updatedAt) return items @@ -616,7 +637,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } try { - const visible = await listVisibleSessionSummaries() + 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)) diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index fcb29fdbcc..99f40a5949 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -220,6 +220,40 @@ describe('session.search', () => { expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') }) + 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('maps missing composition, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) From bd42204e53d38af428c7c5985207dc91503a36bf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:51:47 +0800 Subject: [PATCH 005/709] fix(cli): keep search index ephemeral --- .../feature/2026-07-27-web-session-search.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-27-web-session-search.md | 2 +- .../feature/2026-07-27-web-session-search.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 9 ++++----- apps/web/tests/scaffold.ts | 2 +- docs/config-catalog.md | 6 +++--- packages/session-query/session-query-sqlite/src/index.ts | 6 +++--- 10 files changed, 19 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 1aa4dd3109..1dfb037886 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 421922d25bc61c1813a515e8439af0c7956b2efb -2026-07-27-web-session-search.zh.md: 04035e74fae718dbf65b294b4293d368ecc89cf7 +2026-07-27-web-session-search.md: 8791d02249cc310e768712b3967dcfead3a950e0 +2026-07-27-web-session-search.zh.md: 737f91fbe2c00ac5aa75fb6c30b8b22f80b0854f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 421922d25b..8791d02249 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -10,7 +10,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at a process-owned `.sessions/session-query-.db` path. Process scoping preserves the SQLite backend's single-owner contract when multiple CLI or Web processes run from the same directory. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 04035e74fa..737f91fbe2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -10,7 +10,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会在由单一进程拥有的 `.sessions/session-query-.db` 路径挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。当多个 CLI 或 Web 进程从同一目录运行时,进程级隔离可维持 SQLite 后端的单一所有者契约。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 +Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 5e6fb13cd9..9a5db218c0 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: c1d76e42a6788f48c4dd01bbf71281e1081a411c -README.zh.md: 69bd88ca8fc7086c94017ac7d25ebd55e8585427 +README.md: 0c4ff8d36a89e234512f42d130774fe3717968b9 +README.zh.md: 7e116ecb32061060816f27279a5d3b555a584f6b diff --git a/apps/cli/README.md b/apps/cli/README.md index c1d76e42a6..0c4ff8d36a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable, process-owned SQLite content index at `.sessions/session-query-.db`. The process-specific path preserves the backend's single-owner contract across parallel invocations. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content index. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind. The index starts empty and lazily reconciles live and persisted logs on the first session search of each invocation. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 69bd88ca8f..7e116ecb32 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query-.db` 挂载一个可丢弃、由单一进程拥有的 SQLite 内容索引。该进程专属路径可在并行调用时维持后端的单一所有者契约。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件。该索引从空状态启动,并在每次调用的首次会话搜索时惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index be9303344d..35979b274f 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,14 +89,13 @@ config: root: './.sessions' -# Lazy, process-owned content index for session.search. Opening the database -# at boot does not scan logs; the first search reconciles changed -# live/persisted sessions. The pid prevents concurrent dsh processes in the -# same cwd from sharing one unsupported SQLite owner path. +# Lazy, service-owned content index for session.search. The in-memory database +# cannot be shared across processes or leak derived files across invocations; +# the first search reconciles changed live/persisted sessions for this boot. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: - path: !!js "'./.sessions/session-query-' + process.pid + '.db'" + path: ':memory:' - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f809081d3d..eb61619de0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,7 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Mon, 27 Jul 2026 13:05:59 +0800 Subject: [PATCH 006/709] fix(web): support large search corpora (round 4) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 2 +- .../2026-07-27-web-session-search.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 69 ++++++++++++------- .../apiproxy/tests/api-proxy-search.spec.ts | 50 ++++++++++++-- 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 1dfb037886..721f0f9483 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 8791d02249cc310e768712b3967dcfead3a950e0 -2026-07-27-web-session-search.zh.md: 737f91fbe2c00ac5aa75fb6c30b8b22f80b0854f +2026-07-27-web-session-search.md: e3219f865aa3f13cdb7e806570ef6134dd5bd448 +2026-07-27-web-session-search.zh.md: 27d0efee5d218c2e737fb7378d3fd71c6b13e4ce diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 8791d02249..e3219f865a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -12,7 +12,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 737f91fbe2..27d0efee5d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -12,7 +12,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 7d323dbeea..5079e05965 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: deb1073c2ff9e7a533e595ee3f5537e649660e5b -README.zh.md: e3c521d5f6a09414d087e3fb142852e6d3eb0cb7 +README.md: b9f0fcd8506afda733774868d78fe6e851b4fe2a +README.zh.md: c57990b029b8d1e143396bb13718dbed165e2b47 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index deb1073c2f..b9f0fcd850 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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 passes only those session ids and current-surface user, assistant, and steering messages to the optional `ctx.sessionQuery` service, returns at most 20 session/snippet pairs plus a refine-query bit, and forwards the carrier request signal for cancellation. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. +`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, pages 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. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3c521d5f6..c57990b029 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关仅将这些会话 id 以及当前表层中的 user、assistant 和 steering(中途引导)消息传给可选的 `ctx.sessionQuery` 服务,返回至多 20 个会话/snippet 对和一个提示细化查询的标志位,并转发载体请求信号以支持取消。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,对该结果流分页,直到获得至多 20 个可见会话/snippet 对及一个前瞻项,并在返回前依据从列表推导的授权集合重新校验每个命中。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 367479e698..c9329feea8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,7 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -641,30 +641,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro 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 page = await sessionQuery.searchSessions({ - query: request.payload.query, - sessionFilters: [{ kind: 'id', values: [...visibleIds] }], - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: SESSION_SEARCH_LIMIT, - }, { signal }) - if (isAborted(signal)) return cancelled() - // The filters are the authorization boundary. Re-check the complete - // provider provenance before emitting its snippet so a backend - // regression cannot pair an allowed header with excluded content. - const authorized = page.items.filter(hit => - visibleIds.has(hit.header.id) - && hit.bestMatch.sessionId === hit.header.id - && hit.bestMatch.surface === 'current' - && MESSAGE_TYPES.has(hit.bestMatch.type)) + const authorized: SessionSearchItem[] = [] + const acceptedIds = new Set() + const seenCursors = new Set() + let cursor: SessionSearchCursor | undefined + while (authorized.length <= SESSION_SEARCH_LIMIT) { + if (isAborted(signal)) return cancelled() + const page = await sessionQuery.searchSessions({ + query: request.payload.query, + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: SESSION_SEARCH_LIMIT, + ...cursor === undefined ? {} : { cursor }, + }, { signal }) + if (isAborted(signal)) return cancelled() + // 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 ( + !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 + acceptedIds.add(hit.header.id) + authorized.push({ + sessionId: hit.header.id, + snippet: hit.bestMatch.snippet, + }) + if (authorized.length > SESSION_SEARCH_LIMIT) break + } + if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break + if (seenCursors.has(page.nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(page.nextCursor) + cursor = page.nextCursor + } return ok(request, { - items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ - sessionId: hit.header.id, - snippet: hit.bestMatch.snippet, - })), - hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT, + items: authorized.slice(0, SESSION_SEARCH_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_LIMIT, }) } catch (error: unknown) { if ( diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 99f40a5949..5138398fe3 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -107,7 +107,6 @@ describe('session.search', () => { }, }, ], - nextCursor: 'more' as never, })) ctx.provide('sessionQuery', { searchSessions } as never) const api = createApiProxy(ctx, defaults) @@ -119,7 +118,7 @@ describe('session.search', () => { ok: true, value: { items: [{ sessionId: 'cold', snippet: 'the matching answer' }], - hasMore: true, + hasMore: false, }, }) expect(searchSessions).toHaveBeenCalledOnce() @@ -129,7 +128,6 @@ describe('session.search', () => { ] expect(query).toEqual({ query: 'matching answer', - sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }], eventFilters: [ { kind: 'type', @@ -179,7 +177,6 @@ describe('session.search', () => { withBestMatch(2, { type: 'tool/result' }), withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }), ], - nextCursor: 'more', }), } as never) @@ -192,19 +189,25 @@ describe('session.search', () => { ok: true, value: { items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], - hasMore: true, + hasMore: false, }, }) }) - it('enforces the 20-item Host boundary even if a provider overproduces', async () => { + 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: () => Promise.resolve({ items }), + searchSessions, } as never) const response = await createApiProxy(ctx, defaults).sessions.search( request('match'), @@ -218,6 +221,39 @@ describe('session.search', () => { 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('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(() => 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 () => { From 30503c3b0308752d9f915e8af631d76a1fed754d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:07:23 +0800 Subject: [PATCH 007/709] test(web): type large-corpus search mock (round 5) --- packages/host/apiproxy/tests/api-proxy-search.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 5138398fe3..05e33fb08f 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -235,7 +235,7 @@ describe('session.search', () => { list: () => Promise.resolve(cold), locate: () => undefined, } as never) - const searchSessions = vi.fn(() => Promise.resolve({ + const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({ items: [hit('cold-32750')], })) ctx.provide('sessionQuery', { searchSessions } as never) From a8c28be1ba345bda3e8a90847ca992edcda0668e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:18:05 +0800 Subject: [PATCH 008/709] fix(web): bound search provider work (round 6) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 8 +- .../2026-07-27-web-session-search.zh.md | 8 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 15 +++ .../apiproxy/tests/api-proxy-search.spec.ts | 122 ++++++++++++++++++ 8 files changed, 151 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 721f0f9483..c7ce4dcdd1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: e3219f865aa3f13cdb7e806570ef6134dd5bd448 -2026-07-27-web-session-search.zh.md: 27d0efee5d218c2e737fb7378d3fd71c6b13e4ce +2026-07-27-web-session-search.md: 2e82c9cb0d453ce6e863f8c436cb793979d17a64 +2026-07-27-web-session-search.zh.md: 010b29f80098bce518354992f23cd499e63e0b04 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index e3219f865a..2e82c9cb0d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -12,7 +12,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The Host makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. @@ -20,7 +20,7 @@ Content matching inherits the SQLite backend's normalized literal token/phrase s ## Failure and visibility contract -Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. +Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and only provider hits whose ids occur in that baseline can leave the Host. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint. @@ -35,8 +35,8 @@ While the first or a later content request is pending, the UI keeps immediate me Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. -The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. +The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective provider stream that has not produced enough authorized results within its first 2,000 hits takes the metadata-only failure path instead of consuming unbounded work. ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, provider page and item budgets, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 27d0efee5d..010b29f800 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -12,7 +12,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 @@ -20,7 +20,7 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds ## 故障与可见性契约 -搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 +搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。 @@ -35,8 +35,8 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds 无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 -首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。 +首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差的提供方结果流在前 2,000 个命中内仍未产生足够多的已授权结果,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、提供方页数和单页命中数预算、游标与跨页去重行为、后续页取消及故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5079e05965..b4c9f9f9fb 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: b9f0fcd8506afda733774868d78fe6e851b4fe2a -README.zh.md: c57990b029b8d1e143396bb13718dbed165e2b47 +README.md: 2f062ba9b927ab62518523731d39fd7c52e07c8d +README.zh.md: 72ff415f793b4bdb2068a8240ea42baab79984dc diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b9f0fcd850..2f062ba9b9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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, pages 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. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. +`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 in pages capped at 20 hits, 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. It makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, also returns an `internal` business error so clients can retain metadata-only matches. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c57990b029..72ff415f79 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`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 对及一个前瞻项,并在返回前依据从列表推导的授权集合重新校验每个命中。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c9329feea8..e94f71fd3f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 +/** Provider work budget: at most 100 pages × 20 hits = 2,000 inspected hits. */ +const SESSION_SEARCH_PROVIDER_PAGE_LIMIT = 100 + /** Bound cold-log stat fan-out so an aborted search stops launching new work. */ const COLD_SUMMARY_BATCH_SIZE = 16 @@ -645,8 +648,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const acceptedIds = new Set() const seenCursors = new Set() let cursor: SessionSearchCursor | undefined + let providerPageCount = 0 while (authorized.length <= SESSION_SEARCH_LIMIT) { if (isAborted(signal)) return cancelled() + if (providerPageCount >= SESSION_SEARCH_PROVIDER_PAGE_LIMIT) { + throw new Error( + `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_PAGE_LIMIT}-page work budget`, + ) + } + providerPageCount++ const page = await sessionQuery.searchSessions({ query: request.payload.query, eventFilters: [ @@ -657,6 +667,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...cursor === undefined ? {} : { cursor }, }, { signal }) if (isAborted(signal)) return cancelled() + if (page.items.length > SESSION_SEARCH_LIMIT) { + throw new Error( + `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`, + ) + } // 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 diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 05e33fb08f..840535ba2d 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -225,6 +225,128 @@ describe('session.search', () => { expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) }) + it('fails closed after 100 provider pages with distinct continuation cursors', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + let pageNumber = 0 + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + pageNumber++ + expect(providerRequest.limit).toBe(20) + return Promise.resolve({ + items: [], + nextCursor: `page-${pageNumber}`, + }) + }) + 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-page work budget') + expect(searchSessions).toHaveBeenCalledTimes(100) + }) + + it('rejects an oversized provider page before iterating its items', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const oversized = new Array(21) + const iterate = vi.fn(() => oversized.values()) + Object.defineProperty(oversized, Symbol.iterator, { value: iterate }) + 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') + expect(iterate).not.toHaveBeenCalled() + }) + + it('fails closed when the provider repeats a continuation cursor', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + 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('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( From 40b68cd8d55a7ae57de4d3b87c3afc0dfa849b5d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:24:39 +0800 Subject: [PATCH 009/709] fix(web): harden paged search protocol (round 7) --- packages/host/apiproxy/src/api-proxy.ts | 29 ++++++---- .../apiproxy/tests/api-proxy-search.spec.ts | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e94f71fd3f..8126dd432d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -667,16 +667,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...cursor === undefined ? {} : { cursor }, }, { signal }) if (isAborted(signal)) return cancelled() - if (page.items.length > SESSION_SEARCH_LIMIT) { + const providerItemCount = page.items.length + if (providerItemCount > SESSION_SEARCH_LIMIT) { throw new Error( - `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`, + `session search provider returned ${providerItemCount} items; maximum is ${SESSION_SEARCH_LIMIT}`, ) } // 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) { + // provenance before emitting any snippet. Inspect exactly the + // declared array entries so a custom iterator cannot overproduce. + for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) { + const hit = page.items[itemIndex] + if (hit === undefined) { + throw new Error(`session search provider omitted item at index ${itemIndex}`) + } + if (authorized.length > SESSION_SEARCH_LIMIT) continue if ( !visibleIds.has(hit.header.id) || hit.bestMatch.sessionId !== hit.header.id @@ -689,14 +696,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId: hit.header.id, snippet: hit.bestMatch.snippet, }) - if (authorized.length > SESSION_SEARCH_LIMIT) break } - if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break - if (seenCursors.has(page.nextCursor)) { - throw new Error('session search provider repeated a continuation cursor') + const nextCursor = page.nextCursor + if (nextCursor !== undefined) { + if (seenCursors.has(nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(nextCursor) } - seenCursors.add(page.nextCursor) - cursor = page.nextCursor + if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break + cursor = nextCursor } return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT), diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 840535ba2d..734ddfc499 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -272,6 +272,33 @@ describe('session.search', () => { expect(iterate).not.toHaveBeenCalled() }) + it('inspects only numerically stored items when a compliant page overrides iteration', async () => { + const ctx = await baseContext() + const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of visible) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const stored = visible.slice(0, 1) + const iterate = vi.fn(() => visible.values()) + Object.defineProperty(stored, Symbol.iterator, { value: iterate }) + const searchSessions = vi.fn(() => Promise.resolve({ items: stored })) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('custom-iterator'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible-0', snippet: 'match 0' }], + hasMore: false, + }, + }) + expect(iterate).not.toHaveBeenCalled() + }) + it('fails closed when the provider repeats a continuation cursor', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -292,6 +319,32 @@ describe('session.search', () => { 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)) From ba2925c7041bac2976c362c1a5bec379b2c0af3f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 14:02:35 +0800 Subject: [PATCH 010/709] fix(web): converge search runtime boundaries (round 8) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 8 +- .../2026-07-27-web-session-search.zh.md | 8 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 7 +- docs/config-catalog.md | 7 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 76 +++++-- .../apiproxy/tests/api-proxy-search.spec.ts | 202 +++++++++++++++++- .../session-query-sqlite/README.i18n.yaml | 6 +- .../session-query-sqlite/README.md | 3 + .../session-query-sqlite/README.zh.md | 3 + .../session-query-sqlite/src/index.ts | 30 ++- .../session-query-sqlite/src/schema.ts | 3 +- .../tests/lazy-open.compat.spec.ts | 45 ++++ .../session-query-sqlite/tests/sqlite.spec.ts | 96 ++++++++- scripts/run-gates.ts | 5 + 21 files changed, 469 insertions(+), 54 deletions(-) create mode 100644 packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index c7ce4dcdd1..6124b31fb4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 2e82c9cb0d453ce6e863f8c436cb793979d17a64 -2026-07-27-web-session-search.zh.md: 010b29f80098bce518354992f23cd499e63e0b04 +2026-07-27-web-session-search.md: 8992fdf046c1256ab61278cf5189ba56df8b4ecd +2026-07-27-web-session-search.zh.md: 764e9f3363ae321c55e401cc52b35dcba790a0b4 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 2e82c9cb0d..8992fdf046 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -10,9 +10,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The Host makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Those retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. @@ -35,8 +35,8 @@ While the first or a later content request is pending, the UI keeps immediate me Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. -The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective provider stream that has not produced enough authorized results within its first 2,000 hits takes the metadata-only failure path instead of consuming unbounded work. +The first content query can take longer because it imports and opens SQLite before paying lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective or repeatedly stale provider attempt that does not complete within 100 calls takes the metadata-only failure path instead of consuming unbounded work. ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, provider page and item budgets, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request validation, visible-session filtering, event/surface filters, result and snippet bounds, the shared provider-call budget, stale-generation restarts, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; a Node 22 compatibility subprocess pins warning-free mount and disposal before the first search. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 010b29f800..764e9f3363 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 +Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。这些重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 @@ -35,8 +35,8 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds 无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 -首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差的提供方结果流在前 2,000 个命中内仍未产生足够多的已授权结果,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 +首次内容查询可能耗时更长,因为它要先导入并打开 SQLite,再承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、提供方页数和单页命中数预算、游标与跨页去重行为、后续页取消及故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享提供方调用预算、陈旧世代重启、游标与跨页去重行为、后续页取消及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;一个 Node 22 兼容性子进程将首次搜索前无警告挂载与处置固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 9a5db218c0..4bf441aa6a 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: 0c4ff8d36a89e234512f42d130774fe3717968b9 -README.zh.md: 7e116ecb32061060816f27279a5d3b555a584f6b +README.md: c3a3cbbdd578b0705a7e6c6d62c52dcd9cf6fa60 +README.zh.md: b755a82917e9472b6e788667a4f3387abce3397b diff --git a/apps/cli/README.md b/apps/cli/README.md index 0c4ff8d36a..c3a3cbbdd5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content index. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind. The index starts empty and lazily reconciles live and persisted logs on the first session search of each invocation. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 7e116ecb32..b755a82917 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件。该索引从空状态启动,并在每次调用的首次会话搜索时惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 35979b274f..96e4716c4d 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,13 +89,14 @@ config: root: './.sessions' -# Lazy, service-owned content index for session.search. The in-memory database -# cannot be shared across processes or leak derived files across invocations; -# the first search reconciles changed live/persisted sessions for this boot. +# The service activates at boot, while first-search defers the node:sqlite +# import and in-memory handle so Node 22 startup stays quiet until content +# search actually uses SQLite. That search then reconciles this boot's sources. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: path: ':memory:' + openAt: first-search - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a69d5f1978..5b7415aec3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1057,6 +1057,8 @@ export interface Config extends SessionQueryConfig { * 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. */ @@ -1069,13 +1071,16 @@ export interface Config extends SessionQueryConfig { persistedInspectConcurrency?: number } +/** SQLite module/handle opening phase. */ +export type OpenAt = 'startup' | 'first-search' + /** Supported SQLite journal modes. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:79`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b4c9f9f9fb..39a555e071 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: 2f062ba9b927ab62518523731d39fd7c52e07c8d -README.zh.md: 72ff415f793b4bdb2068a8240ea42baab79984dc +README.md: e61de41a14294b8c1601e5be8cab19fdf780916d +README.zh.md: 7e49ad49aaa9356412f90b37237b06ce65776e1c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 2f062ba9b9..e61de41a14 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,9 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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 in pages capped at 20 hits, 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. It makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, also returns an `internal` business error so clients can retain metadata-only matches. +`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 in pages capped at 20 hits, 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. Returned snippets contain at most 240 Unicode code points; a malformed non-string provider snippet fails closed instead of crossing the RPC 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. Stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, 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 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. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 72ff415f79..7e49ad49aa 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`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 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。返回的 snippet 最多包含 240 个 Unicode 码点;如果提供方返回格式错误的非字符串 snippet,系统会直接失败,而不会让它越过 RPC 边界。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 + +陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始。陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8126dd432d..f22539fefb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,8 +41,11 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 -/** Provider work budget: at most 100 pages × 20 hits = 2,000 inspected hits. */ -const SESSION_SEARCH_PROVIDER_PAGE_LIMIT = 100 +/** Provider work budget: at most 100 calls and 2,000 inspected hits. */ +const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 + +/** Product contract: snippets contain at most 240 Unicode code points. */ +const SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT = 240 /** Bound cold-log stat fan-out so an aborted search stops launching new work. */ const COLD_SUMMARY_BATCH_SIZE = 16 @@ -55,6 +58,28 @@ function isAborted(signal: AbortSignal): boolean { return signal.aborted } +/** Copy at most the product-visible code-point prefix without splitting a surrogate pair. */ +function boundedSessionSearchSnippet(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('session search provider returned a non-string snippet') + } + let end = 0 + for ( + let count = 0; + count < SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT && end < value.length; + count++ + ) { + const first = value.charCodeAt(end) + const hasSurrogatePair = first >= 0xD800 + && first <= 0xDBFF + && end + 1 < value.length + && value.charCodeAt(end + 1) >= 0xDC00 + && value.charCodeAt(end + 1) <= 0xDFFF + end += hasSurrogatePair ? 2 : 1 + } + return end === value.length ? value : value.slice(0, end) +} + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -648,24 +673,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const acceptedIds = new Set() const seenCursors = new Set() let cursor: SessionSearchCursor | undefined - let providerPageCount = 0 + let providerCallCount = 0 while (authorized.length <= SESSION_SEARCH_LIMIT) { if (isAborted(signal)) return cancelled() - if (providerPageCount >= SESSION_SEARCH_PROVIDER_PAGE_LIMIT) { + if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) { throw new Error( - `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_PAGE_LIMIT}-page work budget`, + `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, ) } - providerPageCount++ - const page = await sessionQuery.searchSessions({ - query: request.payload.query, - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: SESSION_SEARCH_LIMIT, - ...cursor === undefined ? {} : { cursor }, - }, { signal }) + providerCallCount++ + const requestedCursor = cursor + 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: SESSION_SEARCH_LIMIT, + ...requestedCursor === undefined ? {} : { cursor: requestedCursor }, + }, { signal }) + } catch (error: unknown) { + if (isAborted(signal)) return cancelled() + 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 > SESSION_SEARCH_LIMIT) { @@ -691,10 +734,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro || !MESSAGE_TYPES.has(hit.bestMatch.type) || acceptedIds.has(hit.header.id) ) continue + const snippet = boundedSessionSearchSnippet(hit.bestMatch.snippet) acceptedIds.add(hit.header.id) authorized.push({ sessionId: hit.header.id, - snippet: hit.bestMatch.snippet, + snippet, }) } const nextCursor = page.nextCursor diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 734ddfc499..0ab2d97792 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -225,7 +225,7 @@ describe('session.search', () => { expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) }) - it('fails closed after 100 provider pages with distinct continuation cursors', async () => { + it('fails closed after 100 provider calls with distinct continuation cursors', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) let pageNumber = 0 @@ -247,10 +247,153 @@ describe('session.search', () => { 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-page work budget') + expect(response.result.error.message).toContain('100-call work budget') expect(searchSessions).toHaveBeenCalledTimes(100) }) + it('restarts a stale continuation from one fresh generation and keeps the 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 searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + switch (searchSessions.mock.calls.length) { + case 1: + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [oldOnly, shared], + nextCursor: 'old-cursor', + }) + case 2: + expect(providerRequest.cursor).toBe('old-cursor') + ctx.sessions.create(late.header.id, { meta: late.header }) + return Promise.reject(stale) + case 3: + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [freshFirst, shared], + nextCursor: 'old-cursor', + }) + case 4: + 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(4) + }) + + 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('rejects an oversized provider page before iterating its items', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -272,6 +415,61 @@ describe('session.search', () => { expect(iterate).not.toHaveBeenCalled() }) + 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 returns a non-string snippet', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ + items: [{ + ...visible, + bestMatch: { ...visible.bestMatch, snippet: 42 }, + }], + }), + } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('malformed-snippet'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toContain('non-string snippet') + expect(response.result).not.toHaveProperty('value') + }) + it('inspects only numerically stored items when a compliant page overrides iteration', async () => { const ctx = await baseContext() const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index 9c5f95f8ce..88e88cc15d 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2 -README.zh.md: 4e11ae9c9b8012045a7f3bab5d5c45724e553303 +# pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md +README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1 +README.zh.md: afa45ad364a92e0268cf40c90dd61b163be0e18f diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index ceffb3ac25..4bf4d979f2 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -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`. | diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index 4e11ae9c9b..afa45ad364 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -16,6 +16,8 @@ 该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,以非变更方式只检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间附加的 owner 无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该实时 owner 脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。 +`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。 + 持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时 owner 消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。 该数据库可丢弃,但 reset 受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态归连接所有。 @@ -25,6 +27,7 @@ | 键 | 默认值 | 契约 | |---|---:|---| | `path` | required | 专用派生索引 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`。 | diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 9f19108180..8e95196663 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -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' @@ -72,6 +72,9 @@ 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 { /** @@ -80,6 +83,8 @@ export interface Config extends SessionQueryConfig { * 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. */ @@ -94,6 +99,7 @@ export interface Config extends SessionQueryConfig { interface ResolvedConfig { path: string + openAt: OpenAt journalMode: JournalMode defaultLimit: number maxLimit: number @@ -175,6 +181,7 @@ export class SessionQuerySqlite extends SessionQueryService { static Config: z = 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), @@ -191,7 +198,7 @@ export class SessionQuerySqlite extends SessionQueryService { readonly config: ResolvedConfig private readonly _instance = randomUUID() - private readonly _ready: Promise + private _ready: Promise | undefined private _db: DatabaseSync | undefined private _persistenceBinding: PersistenceBinding = { identity: Symbol() } private _lastPersistenceIdentity: symbol | undefined @@ -208,7 +215,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 } @@ -225,9 +231,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 { - await this._ensureReady(undefined) + if (this.config.openAt === 'startup') await this._ensureReady(undefined) } override async searchSessions( @@ -296,10 +302,12 @@ export class SessionQuerySqlite extends SessionQueryService { private async _close(): Promise { 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 @@ -315,6 +323,7 @@ export class SessionQuerySqlite extends SessionQueryService { } private async _ensureReady(signal: AbortSignal | undefined): Promise { + this._ready ??= this._open() try { await waitWithAbort(this._ready, signal) } catch (error: unknown) { @@ -946,6 +955,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, @@ -957,6 +967,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) diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..073b42d19e 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -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 } diff --git a/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts b/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts new file mode 100644 index 0000000000..eea18805fa --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts @@ -0,0 +1,45 @@ +/** + * Node 22 startup-output smoke for first-search SQLite opening. + * + * The isolated subprocess omits NODE_OPTIONS so warning suppression cannot + * hide a static node:sqlite import. + */ + +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const root = resolve(import.meta.dirname, '../../../..') + +it('mounts and disposes first-search mode without a SQLite experimental warning', async () => { + const script = ` + import { Context } from 'cordis' + import SessionStore from '@deepseek-ai/dsh-session' + import SessionQuerySqlite from './packages/session-query/session-query-sqlite/src/index.ts' + + const ctx = new Context() + const sessions = await ctx.plugin(SessionStore) + const search = await ctx.plugin(SessionQuerySqlite, { + path: ':memory:', + openAt: 'first-search', + }) + await search.dispose() + await sessions.dispose() + ` + const env = { ...process.env } + delete env.NODE_OPTIONS + const { stderr } = await execFileAsync(process.execPath, [ + '--import', + 'tsx', + '--input-type=module', + '--eval', + script, + ], { + cwd: root, + env, + }) + + expect(stderr).not.toMatch(/ExperimentalWarning: SQLite/) +}) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 2fdd0b1e89..9251f6c7f2 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -177,16 +177,19 @@ async function liveContext(config: ConstructorParameters { - 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) @@ -198,6 +201,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 } + 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 } + const originalOpen = internals._open.bind(internals) + const release = Promise.withResolvers() + const started = Promise.withResolvers() + 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 () => { @@ -522,6 +591,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() @@ -1238,6 +1308,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) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..7905fcfc18 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -261,6 +261,11 @@ function nodeCompatSmokeGates(): Gate[] { 'run', 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', ], { label: 'JSONL Zstandard smoke' }), + pnpmExec('session-query-lazy-open-smoke', [ + 'vitest', + 'run', + 'packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts', + ], { label: 'session-query lazy-open smoke' }), ] } From 0aa7f8c5cf6e682df87de52b24502b2036bc0761 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 14:46:08 +0800 Subject: [PATCH 011/709] fix(web): converge session search boundaries (round 9) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 6 +- .../2026-07-27-web-session-search.zh.md | 6 +- .../tests/lazy-search-startup.compat.spec.ts | 109 ++++++++++ apps/web/tests/navigation-panes.e2e.ts | 10 +- apps/web/tests/scaffold.ts | 2 +- .../lifecycle-chrome/hero.expected.md | 4 +- .../search-results.expected.md | 2 +- packages/client/connection/README.i18n.yaml | 6 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 117 ++++++++--- .../client/connection/tests/fixture.spec.ts | 17 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 36 +++- .../client/ui-workspace/tests/tree.spec.ts | 2 + .../tests/workspace-browser.spec.tsx | 91 +++++++-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 17 +- .../host/apiproxy/src/api/sessions.schema.ts | 25 ++- .../apiproxy/tests/api-proxy-search.spec.ts | 192 +++++++++++++++++- .../apiproxy/tests/client-handler.spec.ts | 14 ++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 + .../tests/lazy-open.compat.spec.ts | 45 ---- scripts/run-gates.ts | 49 ++++- 29 files changed, 629 insertions(+), 157 deletions(-) create mode 100644 apps/cli/tests/lazy-search-startup.compat.spec.ts delete mode 100644 packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 6124b31fb4..c17f464bc5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: 8992fdf046c1256ab61278cf5189ba56df8b4ecd -2026-07-27-web-session-search.zh.md: 764e9f3363ae321c55e401cc52b35dcba790a0b4 +2026-07-27-web-session-search.md: a709719a04a787d9bfcbba0d73263abd84fabcc1 +2026-07-27-web-session-search.zh.md: 980e2638e5a2a819433525c26e0f336c08384409 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index 8992fdf046..a709719a04 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -12,9 +12,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. -The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Those retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points; the wire response schema independently enforces the same code-point bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 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 signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. Content matching inherits the SQLite backend's normalized literal token/phrase semantics. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. @@ -39,4 +39,4 @@ The first content query can take longer because it imports and opens SQLite befo ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result and snippet bounds, the shared provider-call budget, stale-generation restarts, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; a Node 22 compatibility subprocess pins warning-free mount and disposal before the first search. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; the Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 764e9f3363..980e2638e5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -12,9 +12,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。这些重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点;传输响应 schema 会在客户端解析时独立强制执行相同的码点上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 @@ -39,4 +39,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享提供方调用预算、陈旧世代重启、游标与跨页去重行为、后续页取消及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;一个 Node 22 兼容性子进程将首次搜索前无警告挂载与处置固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts new file mode 100644 index 0000000000..96e9d24e1c --- /dev/null +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -0,0 +1,109 @@ +/** + * Node 22 startup-output smoke for the shipped Web CLI composition. + * + * The child runs built artifacts under plain Node with the real cordis.yml. + * Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the + * shipped quiescent disposer. + */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') +const webDist = join(repoRoot, 'apps/web/dist/index.html') +const configPath = join(repoRoot, 'apps/cli/cordis.yml') +const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1' +const builtArtifactsPresent = existsSync(builtBin) && existsSync(webDist) + +interface ConfigRow { + id?: string + config?: { openAt?: unknown } +} + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + construct: value => String(value), +}) +const configSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */ +function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolveRun, rejectRun) => { + const env: NodeJS.ProcessEnv = { + ...process.env, + DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key', + DSH_HOME: join(cwd, '.dsh'), + } + delete env.DEEPSEEK_BASE_URL + delete env.NODE_OPTIONS + delete env.NODE_NO_WARNINGS + const child = spawn(process.execPath, [ + builtBin, + 'web', + '--host', + '127.0.0.1', + '--port', + '0', + ], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let settled = false + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) { + settled = true + child.kill('SIGTERM') + } + }) + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 60_000) + child.on('error', (error) => { + clearTimeout(timer) + rejectRun(error) + }) + child.on('close', (code) => { + clearTimeout(timer) + if (!settled) { + rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + return + } + resolveRun({ stdout, stderr, code: code ?? -1 }) + }) + }) +} + +describe.skipIf(!requireBuiltArtifacts && !builtArtifactsPresent)('built CLI lazy-search startup', () => { + it('boots and disposes the shipped composition without a SQLite startup warning', async () => { + expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true) + expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true) + const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[] + const searchRow = rows.find(row => row.id === 'session-query-sqlite') + expect(searchRow?.config?.openAt).toBe('first-search') + + const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-')) + try { + const result = await runBuiltWeb(cwd) + expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u) + expect(result.code).toBe(0) + expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, 70_000) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 7744ad5c55..d5acb3ee04 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -93,18 +93,18 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) - const search = page.getByPlaceholder('搜索名称或关键词', { exact: false }) + const search = page.getByPlaceholder('Search names or content', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. await search.fill('zzzqx-no-such-session') - await page.getByText('没有匹配结果').waitFor({ timeout: 30_000 }) + await page.getByText('No matching sessions').waitFor({ timeout: 30_000 }) await expect.poll( - () => page.getByRole('tree', { name: '搜索结果' }).getByRole('treeitem').count(), + () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(), { timeout: 10_000 }, ).toBe(0) await search.fill('WATERFALL') - const resultTree = page.getByRole('tree', { name: '搜索结果' }) + const resultTree = page.getByRole('tree', { name: 'Search results' }) const result = resultTree.getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { @@ -120,7 +120,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) - await page.getByRole('button', { name: '清除搜索' }).click() + await page.getByRole('button', { name: 'Clear search' }).click() await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 90_000) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index eb61619de0..c9390af80d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,7 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 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 searchTokens(value: string): string[] { - return value - .normalize('NFD') - .replace(/\p{M}+/gu, '') - .toLowerCase() - .match(/[\p{L}\p{N}\p{Co}]+/gu) ?? [] -} - -/** Count exact contiguous token-phrase occurrences in one fixture document. */ -function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number { - if (phrase.length === 0 || phrase.length > document.length) return 0 - let count = 0 - for (let start = 0; start <= document.length - phrase.length; start++) { - if (phrase.every((token, offset) => document[start + offset] === token)) count++ +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 = '' } - return count + 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 } } -/** One-line fixture excerpt, bounded so the sidebar remains readable. */ -function searchSnippet(value: string): string { - const oneLine = value.replace(/\s+/gu, ' ').trim() - return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}…` +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 { @@ -342,6 +404,8 @@ interface FixtureSearchCandidate { time: number text: string matchCount: number + matchStart: number + matchEnd: number documentLength: number } @@ -628,21 +692,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { details: {}, }) } - const query = searchTokens(request.payload.query) + 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 matchCount = phraseMatchCount(searchTokens(eventText), query) - if (matchCount === 0) return [] + 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: eventText, - matchCount, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, documentLength: Array.from(eventText).length, }] }).sort(compareSearchCandidates)[0] @@ -651,7 +718,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return ok(request, { items: matches.slice(0, 20).map(match => ({ sessionId: match.sessionId, - snippet: searchSnippet(match.text), + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), })), hasMore: matches.length > 20, }) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 9bf0173237..71d171ee0c 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -62,6 +62,23 @@ describe('createFixtureApi', () => { 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, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index f89fbee5d4..f2da1e952a 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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: 9cb919a1a64394d5e116d35bdddfdee738994a02 -README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641 +README.md: badcfc704b456a62a921cb93f6cf637f255fca1f +README.zh.md: 53c43f880ea4ce4f0cfbf633d32f163662e9271f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 9cb919a1a6..badcfc704b 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) 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 modals. -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. 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 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; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b3add7f89c..53c43f880e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 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 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index d703674c01..c2b73165f3 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -27,6 +27,19 @@ import css from './WorkspaceBrowser.module.css' 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) +} const GROUP_BY_ITEMS = [ { type: 'label' as const, id: 'group-by', text: 'Group by' }, @@ -255,7 +268,7 @@ function SearchResults({ return (
-
+
{results.items.map(result => ( ))} {pending && ( -
正在搜索历史…
+
Searching session history…
)} {failed && (
- 历史内容搜索暂时不可用,仍显示名称匹配。 + Content search is temporarily unavailable. Showing name matches.
)} {!pending && results.items.length === 0 && ( -
没有匹配结果
+
No matching sessions
)} {results.hasMore && ( -
仅显示前 20 项,请缩小搜索范围。
+
Showing the first 20 results. Narrow your search.
)}
@@ -308,7 +321,7 @@ 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 = query.trim() + const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', status: 'idle', @@ -439,11 +452,11 @@ export function WorkspaceBrowser({ {/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Rail: the icon is the region's search control. */}
{ if (wide) searchInput.current?.focus() }}> - + + diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index e47613cdaf..a458ca94c7 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -47,7 +47,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': '加载中…', 'browser.truncated': '文件夹过多,仅显示开头部分。', 'browser.showHidden': '显示隐藏文件', - 'browser.hideHidden': '隐藏隐藏文件', }], ['en', { 'browser.title': 'Select Workspace Directory', @@ -63,7 +62,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': 'Loading…', 'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.showHidden': 'Show hidden files', - 'browser.hideHidden': 'Hide hidden files', }], ] try { diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index c29afc935c..31ec5a4927 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -163,7 +163,6 @@ describe('directory-picker-browse client half', () => { expect(injected.t('browser.title')).toBe('选择工作区目录') expect(injected.t('browser.newFolder')).toBe('新建文件夹') expect(injected.t('browser.showHidden')).toBe('显示隐藏文件') - expect(injected.t('browser.hideHidden')).toBe('隐藏隐藏文件') }) it('drives the injected browse calls through the hole entry', async () => { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 624a2c8705..bc2ad81f76 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,11 +107,14 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // Toggle hidden files on. - fireEvent.click(screen.getByRole('button', { name: 'browser.showHidden' })) + // The fixed-label toggle reports its state through aria-pressed. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() - // Toggle hidden files off. - fireEvent.click(screen.getByRole('button', { name: 'browser.hideHidden' })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('false') expect(screen.queryByText('.config')).toBeNull() // Close resets the toggle. b.view.rerender() From c0426142c5fc0a95e67e8ec7adadd8fbeb00e3db Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:35:38 +0800 Subject: [PATCH 051/709] feat(llm-pi-ai): route-keyed profiles with per-request resolution and in-place route swaps providers becomes a dict keyed by provider route, so the composition base and the llm-pi-ai settings section merge per provider and the route set is structural; the pre-release array shape and per-profile provider field fail loud with migration directions. The adapter reads a profiles thunk once per operation and resolves the credential per stream call (literal apiKey, then apiKeyEnv through ctx.credentials with an ambient env fallback, then pi-ai's provider-native discovery), so key, endpoint, and knob changes reach the next request without restarts. Route-set or captured-retry-policy changes re-register the same adapter instance in one synchronous section; an invalid settings snapshot keeps the last good profiles. --- packages/llm/llm-pi-ai/package.json | 4 + packages/llm/llm-pi-ai/src/adapter.ts | 41 ++-- packages/llm/llm-pi-ai/src/config.ts | 85 +++++--- packages/llm/llm-pi-ai/src/index.ts | 104 +++++++-- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 13 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 203 +++++++----------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 116 ++++++++++ packages/llm/llm-pi-ai/tests/mock-server.ts | 82 +++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 5 +- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 6 +- packages/llm/llm-pi-ai/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 12 files changed, 470 insertions(+), 201 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/mock-server.ts diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index e2b639624b..43b97a14f0 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,9 +39,11 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0cc6dda739..fd40c79c73 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -30,15 +30,20 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { resolveProfiles } from './config.ts' -import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}. */ +/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { - /** Validated provider profiles this adapter instance owns. */ - profiles: readonly PiAiProviderProfile[] + /** Current validated profiles by provider route; called once per operation. */ + profiles: () => ReadonlyMap + /** + * Resolve the credential for one already-resolved profile; called once per + * stream call and frozen for that call. `undefined` defers to pi-ai's + * provider-native ambient discovery. + */ + resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -46,7 +51,7 @@ export interface PiAiAdapterOptions { * override, preserving the catalog's API/capability/compatibility metadata. */ function resolvePiModel( - profile: Omit, + profile: ResolvedPiAiProviderProfile, modelId: string, ): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined @@ -58,12 +63,13 @@ function resolvePiModel( /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( - profile: Omit, + profile: ResolvedPiAiProviderProfile, reasoning: ModelThinkingLevel | undefined, + apiKey: string | undefined, ): SimpleStreamOptions { const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning return { - ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...apiKey === undefined ? {} : { apiKey }, ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, @@ -104,19 +110,16 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap - - constructor(options: PiAiAdapterOptions) { + constructor(private readonly config: PiAiAdapterOptions) { super() - this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.profiles.get(provider)?.retryPolicy + return this.config.profiles().get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -132,7 +135,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -165,7 +168,10 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - const profile = this.profiles.get(options.provider) + // One resolution per stream call: the profile snapshot and the credential + // freeze here and hold for this whole request, so an in-flight stream + // never observes a configuration change and the next call re-resolves. + const profile = this.config.profiles().get(options.provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } @@ -174,6 +180,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) + const apiKey = await this.config.resolveApiKey(profile) const consumer = new AbortController() const upstream = options.signal === undefined @@ -184,7 +191,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile, reasoning), + ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 8c7da2badd..b644527097 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,5 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. + * Profiles are a dict keyed by provider route, so the composition base and a + * user-settings layer merge per provider and the route set is structural. * * @module dsh-llm-pi-ai/config */ @@ -7,6 +9,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' @@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -42,18 +46,22 @@ export interface PiAiProviderProfile { retryPolicy?: RetryPolicyConfig } -/** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { +/** Validated profile with its route stamped and every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends Omit { + /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ + provider: string + /** Validated credential reference, when one is configured. */ + apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy } -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } const thinkingBudgets = z.object({ @@ -64,8 +72,8 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - provider: z.string().required(), - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string(), baseURL: z.string(), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -80,54 +88,61 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.array(profile).required(), + providers: z.dict(profile).required(), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * shallow copy suitable for adapter construction. - * @param profiles - configured provider profiles. + * route-keyed map suitable for per-request reads. + * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { - if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') +export function resolveProfiles(providers: Readonly>): Map { + if (Array.isArray(providers)) { + throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') + } + const entries = Object.entries(providers) + if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getBuiltinProviders()) - const seen = new Set() - return profiles.map((source) => { + const resolved = new Map() + for (const [provider, source] of entries) { const legacy = source as PiAiProviderProfile & { + provider?: unknown maxRetries?: unknown maxRetryDelayMs?: unknown } + if ('provider' in legacy) { + throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') + } if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') } - if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) - if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) + if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') + if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { throw new Error( - `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - seen.add(source.provider) - return { - ...source, + const { apiKeyEnv, retryPolicy, ...rest } = source + resolved.set(provider, { + ...rest, + provider, + ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - retryPolicy: resolveRetryPolicy( - source.retryPolicy, - `llm-pi-ai: provider "${source.provider}" retryPolicy`, - ), - ...source.headers === undefined ? {} : { headers: { ...source.headers } }, - ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, - } - }) + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, + ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + }) + } + return resolved } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index da104cb22d..4856dbe7e5 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,22 +1,27 @@ /** - * Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an - * explicit set of provider profiles; requests select a profile by provider and - * resolve the model dynamically from pi-ai's installed catalog. + * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of + * provider routes; requests select a profile by provider and resolve the + * model dynamically from pi-ai's installed catalog. Profile facts resolve per + * request over the optional `llm-pi-ai` user-settings section and the + * optional credential seam, so a changed key, endpoint, or knob reaches the + * next request without a restart; a changed *route set* (or a route's + * registration-captured retry policy) re-registers the same adapter instance + * in place. * * ```yaml * - id: llm * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: - * - provider: openai - * apiKey: !!js process.env.OPENAI_API_KEY + * openai: + * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 - * - provider: anthropic - * apiKey: !!js process.env.ANTHROPIC_API_KEY - * - provider: openrouter - * apiKey: !!js process.env.OPENROUTER_API_KEY + * anthropic: + * apiKeyEnv: ANTHROPIC_API_KEY + * openrouter: + * apiKeyEnv: OPENROUTER_API_KEY * baseURL: https://proxy.example.com/v1 * ``` * @@ -25,20 +30,93 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' +import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile } from './config.ts' +export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] +const NS = settingsNamespace('llm-pi-ai') + +/** The registry captures these per route; a change here must re-register. */ +function registrationFacts(profiles: ReadonlyMap): unknown { + return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) +} + /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { - const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles: config.providers }) - ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ReadonlyMap | undefined + const profiles = (): ReadonlyMap => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing catalog or bound checks: + // keep serving the last good profiles and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + profiles() + + const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise => { + if (profile.apiKey !== undefined) return profile.apiKey + const ref = profile.apiKeyEnv + if (ref === undefined) return undefined + const credentials = ctx.get('credentials') + if (credentials !== undefined) return (await credentials.resolve(ref))?.value + // Without the seam, keep an ambient fallback so a plain cordis.yml + // composition works from the environment alone; an empty variable defers + // to pi-ai's own provider-native discovery like an absent one. + const ambient = process.env[ref] + return ambient !== undefined && ambient.length > 0 ? ambient : undefined + } + + const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // even when a swap runs inside the scoped settings callback below. + let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + let registeredFacts = registrationFacts(profiles()) + const ensureRegistrationFacts = (): void => { + const facts = registrationFacts(profiles()) + if (deepEqualJson(facts, registeredFacts)) return + // The registry captures the route set and each route's retry policy at + // registration: swap the registration in one synchronous section (same + // adapter instance, no NO_ADAPTER window). + disposeRoutes() + disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + registeredFacts = facts + } + + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(NS, Config, { base: config }) + current = () => scope.get() + sctx.effect(() => () => { + // Settings detached (provider disposed or reloading): fall back to the + // composition entry so the plugin keeps working exactly as configured. + current = () => config + ensureRegistrationFacts() + }) + ensureRegistrationFacts() + scope.watch(() => { + ensureRegistrationFacts() + }) + }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 352f2067d8..a3a949fea2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial = {} contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'deepseek', - ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, - ...config, - }], + providers: { + deepseek: { + ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, + ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, + ...config, + }, + }, }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb70b6d3b8..02d7af5b2d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' @@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' - -interface MockServer { - url: string - paths: string[] - requests: unknown[] - headers: IncomingMessage['headers'][] - readonly closedResponses: number - responseClosed: Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' afterEach(async () => { vi.unstubAllEnvs() - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() }) -async function mockServer(script: { - status?: number - events?: string[] - body?: string - delayMs?: number - headers?: Record -}[]): Promise { - const paths: string[] = [] - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - let closedResponses = 0 - const responseClosed = Promise.withResolvers() - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - response.on('close', () => { - closedResponses += 1 - responseClosed.resolve(undefined) - }) - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - paths.push(request.url ?? '') - requests.push(body.length === 0 ? undefined : JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } - if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) - response.end(behavior.body ?? '{}') - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - let index = 0 - const writeNext = (): void => { - const event = behavior.events?.[index++] - if (event === undefined) { response.end(); return } - response.write(`data: ${event}\n\n`) - if (behavior.delayMs === undefined) writeNext() - else setTimeout(writeNext, behavior.delayMs) - } - writeNext() - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - paths, - requests, - headers, - responseClosed: responseClosed.promise, - get closedResponses() { return closedResponses }, - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }], + providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, }) return ctx } +/** Direct adapter over the real profile resolver, with literal-key resolution. */ +function adapterOf(providers: Record): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles(providers), + resolveApiKey: profile => Promise.resolve(profile.apiKey), + }) +} + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ - profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }], + ctx.llm.registerAdapter(['deepseek'], adapterOf({ + deepseek: { apiKey: 'test-key', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - apiKey: 'test-key', - baseURL: `${server.url}/api/projects/openai/openai/v1`, - headers: { 'api-key': 'test-key', Authorization: '' }, - }], + providers: { + openai: { + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key', Authorization: '' }, + }, + }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) expect(result.finish.kind).toBe('error') @@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [ - { - provider: 'openai', + providers: { + openai: { retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, }, }, - { provider: 'anthropic' }, - ], + anthropic: {}, + }, }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, @@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => { it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] }) + await ctx.plugin(LlmPiAi, { providers: { openai: {} } }) const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', @@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + providers: { deepseek: {}, openai: {} }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) @@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => { const supported = new Context() await supported.plugin(LlmService) await supported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'max' }], + providers: { deepseek: { reasoning: 'max' } }, }) await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) @@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => { const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'medium' }], + providers: { deepseek: { reasoning: 'medium' } }, }) await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) @@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => { const disabled = new Context() await disabled.plugin(LlmService) await disabled.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'off' }], + providers: { deepseek: { reasoning: 'off' } }, }) await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) @@ -443,24 +377,45 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) - it('validates empty, duplicate, unknown, and explicitly blank profiles', () => { - expect(() => resolveProfiles([])).toThrow(/at least one/) - expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/) - expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/) - expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) + it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') + }) + + it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', '') + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + }) + + it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { + expect(() => resolveProfiles({})).toThrow(/at least one/) + expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // The pre-release array shape and its per-profile provider field fail + // loud with migration directions instead of half-working. + expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) + expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) + expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) + expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) it.each(['maxRetries', 'maxRetryDelayMs'] as const)( 'rejects removed profile field %s instead of silently restoring hidden SDK retries', async (field) => { - const legacy = { provider: 'openai', [field]: 2 } - expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const legacy = { [field]: 2 } + expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i) const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } })) .rejects.toThrow(/removed.*agent recovery/i) }, ) @@ -476,30 +431,26 @@ describe('provider profile lifecycle', () => { for (const entry of invalid) { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } })) .rejects.toThrow() } }) it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { - expect(() => resolveProfiles([{ - provider: 'openai', - retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, - }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + expect(() => resolveProfiles({ + openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } }, + })).toThrow(/retryPolicy\.backoff\.jitterRatio/) const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - }], + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } }, })).rejects.toThrow(/retryPolicy/) expect(ctx.llm.listProviders()).toEqual([]) }) it('constructs the adapter directly and rejects routes it does not own', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) + const adapter = adapterOf({ openai: {} }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) @@ -511,12 +462,12 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) - it('validates direct-constructor profiles at the embedding boundary', () => { - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + it('validates profiles at the shared resolver boundary', () => { + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: 0 }, })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, })).toThrow(/streamIdleTimeoutMs.*no greater/) }) }) @@ -527,7 +478,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -548,7 +499,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -562,7 +513,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..5b7c9e5e3a --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-pi-ai') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +/** Real dynamic composition mirroring the deepseek twin's harness. */ +async function boot(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('request-level dynamic profiles', () => { + it('adds a provider route from settings and drops it when the user layer resets', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + }) + + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer live-key') + + // Reset the user layer: the settings-born route unregisters, the + // composition route stays. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + }) + + it('rotates the per-request credential referenced by apiKeyEnv', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + + await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + }) + + it('re-registers routes in place when a captured retry policy changes', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + await ctx.settings.update(NS, { + providers: { + openai: { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }, + }, + }) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) + + it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + // Schema-valid but catalog-invalid: the resolver rejects it and the + // last good route set keeps serving. + await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/mock-server.ts b/packages/llm/llm-pi-ai/tests/mock-server.ts new file mode 100644 index 0000000000..573c61a9a2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +export interface MockServer { + url: string + paths: string[] + requests: unknown[] + headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation in pi-ai's chat-completions shape. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local provider stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { + const paths: string[] = [] + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + paths.push(request.url ?? '') + requests.push(body.length === 0 ? undefined : JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } + if (behavior.status !== undefined && behavior.status !== 200) { + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) + response.end(behavior.body ?? '{}') + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + let index = 0 + const writeNext = (): void => { + const event = behavior.events?.[index++] + if (event === undefined) { response.end(); return } + response.write(`data: ${event}\n\n`) + if (behavior.delayMs === undefined) writeNext() + else setTimeout(writeNext, behavior.delayMs) + } + writeNext() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d08e9b93d..f59859bfad 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -43,12 +43,11 @@ async function harness(): Promise { contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: providerCases.map(profile => ({ - provider: profile.provider, + providers: Object.fromEntries(providerCases.map(profile => [profile.provider, { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, ...profile.headers === undefined ? {} : { headers: profile.headers }, - })), + }])), }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index d96297c242..3f12ef4460 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { }) import { PiAiAdapter } from '../src/adapter.ts' +import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) @@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const adapter = new PiAiAdapter({ + profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + resolveApiKey: () => Promise.resolve('test-key'), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'openai', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7f3ae3f7d..1f28171d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2977,6 +2977,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2986,6 +2989,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout From 60383efef93e083763f7b86239afc7e4798c181e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:42:11 +0800 Subject: [PATCH 052/709] feat(host): blur cancels path editing; scrollbar clearance in the miller columns --- .../src/client/DirectoryBrowser.module.css | 11 ++++- .../src/client/DirectoryBrowser.tsx | 42 +++++++++++-------- .../tests/directory-browser.spec.tsx | 14 +++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index a2c712c811..444ff19cbf 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,7 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* Columns already end in an 8px scrollbar clearance, so the divider only + * needs a slim gap of its own on each side. */ + gap: 12px; overflow-x: auto; scrollbar-width: none; } @@ -136,7 +138,9 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } /* Two-pane columns split the row evenly around the divider; 256px is the @@ -149,6 +153,9 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; + /* The overlay scrollbar paints at the column's edge; keep the row pills + * clear of the thumb. */ + padding-right: 8px; } .columnWide { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 149e04d87e..5c3391454e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -200,6 +200,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) + /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ @@ -382,25 +401,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - // Cancel also withdraws a navigation the editor already - // launched: its late success must not jump to the - // cancelled path, so the pending request is superseded - // and the view leaves the loading state. - supersede() - setLoading(false) - setPathDraft(null) - setError(null) - // Editing may have superseded the selection's preview - // request; a selection with no preview would render a - // half-empty two-pane view, so cancel falls back to the - // single-pane level. - if (child === null) setSelected(null) - // With no level listed yet (the editor superseded the - // initial home listing), plain cancellation would leave a - // permanently blank picker: restart the home listing. - if (parent === null) navigate() + cancelPathEdit() } }} + // Clicking anywhere outside the editor reads as leaving it: + // focus loss cancels the edit like Escape. Enter keeps focus + // in the input while its navigation is in flight, so a + // submitted path is never withdrawn by this handler. + onBlur={cancelPathEdit} /> )}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index bc2ad81f76..d4fc9e2b89 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -220,6 +220,20 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/somewhere/else' } }) + // Focus moving anywhere outside the editor abandons the draft like Escape. + fireEvent.blur(input) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The crumb view is back and the abandoned draft was never navigated to. + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { // The initial home listing hangs; Edit Path supersedes it while parent // is still null, and Escape must not strand a blank picker. From d77db29f01e798dcef9dcab7edf1fff95bfddbdb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:44:24 +0800 Subject: [PATCH 053/709] test: real-Loader dynamic composition, keyless onboarding snapshot, and .env-only e2e llm-deepseek gains a Loader+Include composition spec proving external settings.yaml/.env edits reach the very next request, and a real-API e2e where only a credentials-local document holds the key. The headless example pins the first-run missing-credential UX as a keyless stream-json snapshot (new credentials.cordis.snapshot.yml scenario); runLoaderSmoke learns expectedExitCode so a designed failure surface can be pinned instead of masked. --- .../credentials.cordis.snapshot.yml | 27 ++++ .../headless-agent/tests/headless.snapshot.ts | 33 ++++ .../stream-json.expected.jsonl | 8 + examples/package.json | 8 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 34 ++++- .../tests/loader-composition.spec.ts | 142 ++++++++++++++++++ packages/support/loader-smoke/src/index.ts | 12 +- .../loader-smoke/tests/loader-smoke.spec.ts | 27 +++- pnpm-lock.yaml | 6 + 9 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 examples/headless-agent/credentials.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl create mode 100644 packages/llm/llm-deepseek/tests/loader-composition.spec.ts diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml new file mode 100644 index 0000000000..7e85b90df7 --- /dev/null +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -0,0 +1,27 @@ +# Keyless dynamic-configuration composition: the settings and credentials +# providers live under the run cwd, no API key exists anywhere, and the +# deepseek route still registers — so the prompt fails with the actionable +# MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: settings + name: '@deepseek-ai/dsh-settings-local' + config: + dshHome: ./.dsh + debounceMs: 10 + - id: credentials + name: '@deepseek-ai/dsh-credentials-local' + config: + dshHome: ./.dsh + # The endpoint is never dialed: credential resolution fails first. + - id: llm-deepseek-keyless + name: '@deepseek-ai/dsh-llm-deepseek' + config: + baseURL: 'http://127.0.0.1:9' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 2581d8a042..fba7bf7338 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -27,6 +27,8 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') +const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -168,6 +170,37 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('surfaces actionable missing-credential guidance through the one-shot app', async () => { + const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'missing-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-missing-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // First-run posture: no key in the environment, none under ./.dsh. + DEEPSEEK_API_KEY: '', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + // The designed failure surface: the one-shot app reports the failed turn. + expectedExitCode: 1, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe( + 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' + + ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,' + + ' or export DEEPSEEK_API_KEY\n', + ) + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..d7d72f6a86 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -0,0 +1,8 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..35d9a74e3e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", @@ -32,7 +33,6 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", @@ -44,13 +44,15 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-settings-local": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f6d97031c4..e59af1185d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' @@ -53,6 +57,34 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('serves a real request with the key held only by a credentials-local document', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, {}) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + it('flash dynamically switches from off to high', async () => { const ctx = await harness(FLASH, { reasoningEffort: 'off' }) const withoutThinking = await assemble(ctx,{ diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9ca8c87367 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -0,0 +1,142 @@ +/** + * Real-composition guard for the dynamic-configuration chain: LlmService, + * settings-local, credentials-local, and llm-deepseek boot from a test-only + * cordis.yml through the actual Loader + Include path, external edits of + * settings.yaml and .env hot-publish through their providers, and the very + * next request carries the fresh base URL and credential. The same adapter + * composition without settings or credentials entries keeps entry-config + * behavior — the documented optional-inject fallback. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function loadComposition( + options: { withDynamic: boolean; baseURL: string }, +): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) + const settingsPath = join(root, 'settings.yaml') + const envPath = join(root, '.env') + if (options.withDynamic) { + await writeFile(settingsPath, '# personal settings\n') + await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + ...options.withDynamic + ? [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: credentials', + " name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: ${JSON.stringify(envPath)}`, + ' debounceMs: 10', + ] + : [], + '- id: llm-deepseek', + " name: '@deepseek-ai/dsh-llm-deepseek'", + ' config:', + ` baseURL: ${JSON.stringify(options.baseURL)}`, + ...options.withDynamic ? [] : [' apiKey: entry-key'], + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath, envPath } +} + +describe('llm-deepseek real dynamic composition', () => { + it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + + // External edits, exactly as a user or the web UI would leave them on disk. + await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) + await vi.waitFor(() => { + expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) + }, { timeout: 5000 }) + await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await vi.waitFor(async () => { + expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) + }, { timeout: 5000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + }) + + it('boots the same adapter without settings or credentials entries on entry config alone', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) + + expect(ctx.get('settings')).toBeUndefined() + expect(ctx.get('credentials')).toBeUndefined() + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + }) +}) diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index e573684a4e..4ee5cfec7d 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -141,6 +141,13 @@ export interface LoaderSmokeOptions { readonly prepare?: (cwd: string) => Promise | void /** Optional world-state assertion run in the isolated cwd before cleanup. */ readonly inspect?: (cwd: string) => Promise | void + /** + * Exact process exit code this smoke expects; defaults to `0`. Scenarios + * pinning a designed failure surface (a one-shot turn ending in an error + * result) declare its nonzero exit here, and a run that exits any other + * way — including succeeding — still fails the smoke. + */ + readonly expectedExitCode?: number } /** Captured output from a Loader smoke that exited successfully. */ @@ -187,8 +194,9 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { libBinScript: fixture('fail'), configPath, tsconfigPath, - })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') + })).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed') + }) + + it('accepts a declared expected failure exit and rejects any other outcome', async () => { + // A scenario pinning a designed failure surface declares its exit code… + const declared = await runLoaderSmoke({ + label: 'declared failure fixture', + tempDirPrefix: 'loader-smoke-declared-fail-', + binScript: fixture('fail'), + libBinScript: fixture('fail'), + configPath, + tsconfigPath, + expectedExitCode: 7, + }) + expect(declared.stderr).toBe('fixture failed\n') + + // …and a run that succeeds instead still fails the smoke. + await expect(runLoaderSmoke({ + label: 'unexpectedly clean fixture', + tempDirPrefix: 'loader-smoke-clean-', + binScript: fixture('success'), + libBinScript: fixture('success'), + configPath, + tsconfigPath, + expectedExitCode: 7, + })).rejects.toThrow(/exited 0 \(expected 7\)/) }) it('kills a process at its deadline and reports captured output', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f28171d9e..de87482bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:* version: link:../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:* + version: link:../packages/credentials/credentials-local '@deepseek-ai/dsh-fs-local': specifier: workspace:* version: link:../packages/fs/fs-local @@ -529,6 +532,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:* + version: link:../packages/settings/settings-local '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local From e561c28232a273f834e66e557e8375a3bb2cf7d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:47:56 +0800 Subject: [PATCH 054/709] feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail --- .../src/client/DirectoryBrowser.tsx | 44 +++++++++++++++-- .../tests/directory-browser.spec.tsx | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c3391454e..01e69f314c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -12,7 +12,10 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,18 +63,45 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ +function separatorOf(path: string): string { + return path.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(draft) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean showHidden: boolean + filterPrefix: string | null }) { + const visible = entries.filter((entry) => { + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return (
- {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { + {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // otherwise close the editor via navigate's draft reset. supersede() setLoading(false) - setPathDraft(selected?.path ?? parent?.path ?? '') + // Seed with a trailing separator so typing immediately + // continues into child names (and prefix-filters below). + const base = selected?.path ?? parent?.path ?? '' + const sep = separatorOf(base) + setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} wide={!twoPane} showHidden={showHidden} + filterPrefix={draftPrefixFor(parent, pathDraft)} /> )} {twoPane && } @@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} wide={false} showHidden={showHidden} + filterPrefix={draftPrefixFor(child, pathDraft)} /> )}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d4fc9e2b89..98f3bd6e4f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - expect(input.value).toBe(HOME) + // The editor seeds with a trailing separator so typing continues into + // child names. + expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) @@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seeded empty segment leaves the level as-is: hidden stays hidden. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Case-insensitive prefix narrows the rows. + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A dot-led prefix names hidden entries, so it reveals the match. + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + expect(screen.getByRole('listitem').textContent).toBe('.config') + // A prefix matching nothing empties the level (no stale rows linger). + fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) + expect(screen.queryByRole('listitem')).toBeNull() + // A draft naming some other directory (or none) leaves the level whole. + fireEvent.change(input, { target: { value: 'no-separator' } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { + const ROOT = 'C:\\' + const windowsListing: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [ + { name: 'Program Files', path: `${ROOT}Program Files`, hidden: false }, + { name: 'Users', path: `${ROOT}Users`, hidden: false }, + ], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => windowsListing) }) + await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The root already ends in its separator: no doubled backslash. + expect(input.value).toBe(ROOT) + fireEvent.change(input, { target: { value: `${ROOT}u` } }) + expect(screen.getByRole('listitem').textContent).toBe('Users') + }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 7714c9fa8b4c890ebc495e766a9d2f778e141953 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:52:44 +0800 Subject: [PATCH 055/709] doc(host): document the show-hidden toggle and path-draft prefix filter; snapshot the flow --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 33 +++++++++++++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..6f21a60e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 +2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..3f5e1436f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..09a3e20f7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 86a20e73fe..32d44b6fff 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -214,6 +214,39 @@ it('adopts a directory through the composed in-app browse flow and lands in its }) }) +it('reveals hidden fixture entries via the footer toggle and prefix-filters from the path draft', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open local folder…' })) + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + await within(dialog).findByText('Documents', {}, { timeout: 10_000 }) + // The host flags .config hidden; the level filters it until the + // fixed-label footer toggle presses on (state lives in aria-pressed). + expect(within(dialog).queryByText('.config')).toBeNull() + const toggle = within(dialog).getByRole('button', { name: '显示隐藏文件' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + fireEvent.click(toggle) + expect(within(dialog).queryByText('.config')).toBeNull() + // The path editor seeds the level's path with a trailing separator and the + // draft's final segment prefix-filters the listed rows while typing. + fireEvent.click(within(dialog).getByRole('button', { name: '编辑路径' })) + const input = within(dialog).getByLabelText('编辑路径') + expect(input.value).toBe('/home/fixture/') + fireEvent.change(input, { target: { value: '/home/fixture/do' } }) + expect(within(dialog).getByText('Documents')).toBeDefined() + expect(within(dialog).getByText('Downloads')).toBeDefined() + expect(within(dialog).queryByText('.config')).toBeNull() + // A dot-led prefix names hidden entries, so its matches surface. + fireEvent.change(input, { target: { value: '/home/fixture/.c' } }) + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + expect(within(dialog).queryByText('Documents')).toBeNull() +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..f40742fc0d 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca +README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..9772baa2a6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..682495fe10 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 From 7639f4cb68e32102dce67a7caf30260cf5ff104f Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 29 Jul 2026 14:12:01 +0800 Subject: [PATCH 056/709] feat(web): answerable ask_user_question flow with toolview verdict row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending question now owns exactly two surfaces: the redesigned QuestionComposer takeover (footer pager, checkbox multi-select, always-visible custom input, locale-injected bilingual chrome) collects the answers, and a dedicated ask_user_question toolview row reports the interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED), or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows to approval waits only. Toolview leading icons and the hover chevron unify on the tertiary label color, the checklist glyph matches the 14px figma extract, and dev-watch registers CSS modules so css-only edits rebuild. --- ...29-ask-question-web-presentation.i18n.yaml | 6 + ...026-07-29-ask-question-web-presentation.md | 45 +++ ...-07-29-ask-question-web-presentation.zh.md | 45 +++ docs/event-producer-consumer.md | 2 +- packages/client/tsdown.client.ts | 5 +- .../ui-conversation/src/client/apply.ts | 4 + .../src/client/chat/ChatView.tsx | 5 +- .../src/client/chat/PendingCard.tsx | 27 +- .../src/client/chat/ToolRow.module.css | 10 - .../src/client/chat/ToolRow.tsx | 5 +- .../src/client/toolviews/ask-question-row.tsx | 94 ++++++ .../src/client/toolviews/todo-row.module.css | 58 ---- .../src/client/toolviews/todo-row.tsx | 63 ++-- .../tests/ask-question-row.spec.tsx | 130 ++++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../tests/coverage-tails.spec.tsx | 14 +- .../ui-conversation/tests/todo-panel.spec.tsx | 32 +- .../client/ui-primitives/src/icons/index.tsx | 39 ++- .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-question/README.i18n.yaml | 6 +- packages/client/ui-question/README.md | 2 + packages/client/ui-question/README.zh.md | 2 + packages/client/ui-question/package.json | 4 +- .../src/client/QuestionComposer.module.css | 280 ++++++++++-------- .../src/client/QuestionComposer.tsx | 206 +++++++------ .../ui-question/src/client/contract/slots.ts | 30 +- .../client/ui-question/src/client/index.ts | 50 +++- .../client/ui-question/src/client/locales.ts | 39 +++ .../ui-question/tests/browser-plugin.spec.ts | 52 +++- .../tests/question-composer.spec.tsx | 48 +-- packages/client/ui-question/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 32 files changed, 869 insertions(+), 450 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md create mode 100644 packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx delete mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css create mode 100644 packages/client/ui-conversation/tests/ask-question-row.spec.tsx create mode 100644 packages/client/ui-question/src/client/locales.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml new file mode 100644 index 0000000000..6954c289bd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55 +2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md new file mode 100644 index 0000000000..90eeb3cdcc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -0,0 +1,45 @@ +# Agent Note: Ask-question Web presentation + +Status: implemented + +English | [中文](2026-07-29-ask-question-web-presentation.zh.md) + +## Problem + +The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`. + +Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text. + +## Decision + +A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap. + +The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. + +Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. + +Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher. + +## Alternatives considered + +**Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. + +**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. + +**Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. + +**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. + +**Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. + +## Consequences + +`ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. + +The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships. + +`ui-question` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. + +## Verification + +`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-question` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md new file mode 100644 index 0000000000..5bb19d3a68 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -0,0 +1,45 @@ +# Agent Note:Ask-question Web 呈现 + +Status: implemented + +[English](2026-07-29-ask-question-web-presentation.md) | 中文 + +## 问题 + +Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON,因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。 + +另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。 + +## 决定 + +一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。 + +输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 + +输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 + +两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。 + +## 曾考虑的替代方案 + +**继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 + +**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 + +**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 + +**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 + +**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 + +## 后果 + +`ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。 + +行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。 + +`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其契约(`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`。 + +## 验证 + +`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e9e5bb6e6a..2ab220305c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | -| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..6b004c80b4 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX }, - async load(virtualId: string) { + async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) { if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) + // Virtual modules hide the real file from the watcher; register it so + // dev-web rebuilds on a css-only edit. + this.addWatchFile?.(fileId) const source = await readFile(fileId) const { code, exports: cssExports } = transform({ filename: fileId, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 62801ca0b8..48dee62787 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { todoToolview } from './toolviews/todo-row.tsx' +import { askQuestionToolview } from './toolviews/ask-question-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' @@ -187,6 +188,9 @@ export function apply(ctx: Context): void { // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) + // The ask_user_question row: waiting/answered/cancelled interaction outcome. + ctx.plugin(askQuestionToolview) + // The plan strip rides the input dock above the queue rows (same posture). ctx.plugin(todoDockEntry) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index deb7f09f6c..e5d80d52c6 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio ))}
)} - {pending.map(item => )} + {/* Approval waits only: a pending question already shows as the + ask_user_question row (waiting state) plus the composer takeover. */} + {pending.filter(item => item.kind === 'approval') + .map(item => )} {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index b6825aed9a..5a2076fe85 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,31 +1,22 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: approval placeholder card (visible, not answerable — the +// composer-takeover approval panel is a P-II item; wire pending semantics +// already exist so the flow must show them). Question waits render through +// the ask_user_question toolview row + the composer takeover instead. import { memo } from 'react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: PendingWait<'approval'> } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
- {item.kind === 'approval' ? ( - <> -
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
} - - ) : ( - <> -
等待回答({item.payload.questions.length} 题)
- - - )} -
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
} +
请在原客户端处理(web 端审批后续里程碑提供)
) }) diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 018529961f..c18bbefb01 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -62,12 +62,6 @@ color: var(--dsw-alias-label-tertiary); } -/* The others-variant sparkle glyph is one gray step darker than the icon - family in the source design. */ -.root[data-variant='others'] .leading { - color: var(--dsw-alias-label-secondary); -} - /* Cordis lifecycle tools retain their generic row mechanics while carrying a shared product accent and tool-owned action title. */ .root[data-tool^='cordis_'] .leading, @@ -87,10 +81,6 @@ button.leading { cursor: pointer; } -.chevron { - color: var(--dsw-alias-label-secondary); -} - /* Hover preview on expandable rows: the idle tool icon crossfades (100ms) into a down chevron before the row is opened. The chevron overlays the icon cell absolutely so both can stay mounted for the opacity transition. */ diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 5c5d059292..6abca0d739 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -7,7 +7,6 @@ // expandable content, retiring the details-panel handoff where feasible. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' -import clsx from 'clsx' import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' @@ -74,12 +73,12 @@ export function ToolRow({ ? ( <> {icon} - + ) : icon const leading = open - ? + ? : leadingFor(state, collapsedIcon) return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx new file mode 100644 index 0000000000..3ba94cc438 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx @@ -0,0 +1,94 @@ +// ask_user_question toolview: question-flavored summary row replacing the +// generic "Tool call" card, registered into the keyed +// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow +// (chrome, running sweep, leading expansion) and swaps in the interaction +// outcome — `waiting` while pending, answered-count once settled, `cancelled` +// when the user dismissed the whole set — because the questions themselves +// render in the composer takeover. + +import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { Context } from 'cordis' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' + +/** One parsed answer entry, shape-checked (result JSON crosses the wire). */ +interface AnswerEntry { selected?: unknown; custom?: unknown } + +function isAnswer(value: unknown): value is AnswerEntry { + return typeof value === 'object' && value !== null +} + +/** `${answered}/${total} answered` off the result JSON (a skipped question has + * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */ +function answeredSummary(text: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) return null + const answers = (parsed as { answers?: unknown }).answers + if (!Array.isArray(answers) || !answers.every(isAnswer)) return null + const answered = answers.filter(a => + (Array.isArray(a.selected) && a.selected.length > 0) + || (typeof a.custom === 'string' && a.custom !== '')).length + return `${answered}/${answers.length} answered` +} + +/** One-line question-interaction row (row click opens details; leading toggle + * expands the raw args). */ +export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + // Composer verdicts settle the call as specific UserInteractionErrors + // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own + // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the + // question was pending. Both name their verdict instead of the generic + // failed shape, and the abort keeps the shared stopped (amber) semantics of + // any other interrupted tool call. + const code = 'kind' in block ? block.error?.code : undefined + let summary = model.summary + let state = model.state + if (code === 'ASK_CANCELLED') { + summary = 'cancelled' + } else if (code === 'ASK_ABORTED') { + summary = 'interrupted' + state = 'stopped' + } else if (model.state === 'running') { + summary = 'waiting' + } else if ('kind' in block && model.state === 'ok') { + const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('') + summary = answeredSummary(text) ?? model.summary + } + return ( + } + title="Ask question" + summary={summary} + body={model.body} + state={state} + onOpenDetails={openDetails} + /> + ) +} + +/** + * The ask-question row as a plain registrant plugin, riding the same + * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the + * chat entry (and with it the 'conversation.chat.toolview' declaration) is on + * the ledger. + */ +export const askQuestionToolview = { + name: 'ask-question-toolview', + inject: ['slots', 'conversation'], + /** + * Register the ask-question row into the chat view's keyed toolview hole. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css deleted file mode 100644 index 1a1b142b3a..0000000000 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ /dev/null @@ -1,58 +0,0 @@ -/* todo_write plan-update row: ToolRow chrome (figma 780:53675) — - [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */ - -.row { - display: flex; - align-items: center; - height: 24px; - min-width: 0; - cursor: pointer; - border-radius: 6px; -} - -.leading { - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - color: var(--dsw-alias-label-tertiary); -} - -.title { - flex: none; - font-size: 14px; - line-height: 24px; - font-weight: 500; /* figma wt510, rendered 500 */ - color: var(--dsw-alias-label-primary-dimmed); -} - -.sep { - flex: none; - width: 2px; - height: 2px; - border-radius: 1px; - margin: 0 8px; - background: var(--dsw-alias-label-caption); -} - -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); -} - -.err { - flex: none; - margin-left: 8px; - color: var(--dsw-alias-state-error-primary); - font-size: 11px; - line-height: 16px; -} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a47322b614..2d72cfc700 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,16 +1,16 @@ // todo_write toolview: plan-flavored summary row replacing the generic // "Tool call" card, registered into the keyed 'conversation.chat.toolview' // hole like the bash sample (a product registration, not a sample). The row -// summarizes the written list (counts + active item) from the call args; the +// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a +// summary of the written list (counts + active item) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. Chrome matches ToolRow (figma 780:53675). +// row stays one line. -import type { KeyboardEvent } from 'react' +import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from 'cordis' -import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' -import css from './todo-row.module.css' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' /** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ interface TodoWriteItem { content?: unknown; status?: unknown } @@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null { : head } -/** Leading-slot state substitution matches ToolRow / bash: icon yields to the - * state semantic while running or failed; ok keeps the checklist glyph. */ -function leadingFor(state: ToolRowState) { - switch (state) { - case 'running': return - case 'error': return - case 'stopped': return - default: return - } -} - -/** One-line plan update row (click opens the raw args in details). Non-ok - * execution states keep the generic row's dot semantics — a cancelled call - * wrote no todo/write, so it must not read as a completed update. */ +/** One-line plan update row (row click opens details; leading toggle expands + * the raw args). Non-ok execution states keep the shared row's dot semantics + * — a cancelled call wrote no todo/write, so it must not read as a completed + * update. */ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? model.summary - // Button semantics, not a - - -
+
@@ -211,7 +189,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { return ( ) })} -
- {hasOptions && ( - - )} - {draft.customOpen && ( + {hasOptions + ? ( +
+ {question.multiSelect === true + ? ( + + ) + : ( + + )} + { + const value = event.target.value + updateDraft(current => ({ + ...current, selected: [], custom: value, skipped: false, + })) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isComposing(event)) { + event.preventDefault() + continueFlow() + } + }} + /> +
+ ) + : (