From a43020742719b2bbf94334b9c42e3f46097314a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 26 Jul 2026 14:09:31 +0800 Subject: [PATCH 01/85] 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 02/85] 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 03/85] 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 04/85] 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 05/85] 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 06/85] 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 07/85] 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 08/85] 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 09/85] 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 10/85] 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 11/85] 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() }}> - + + ) : ( + {model.summary} + )} +
+ {diff !== null && ( + + )} +
+ ) +} + +/** + * The file-mutation rows as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const fileMutationToolview = { + name: 'file-mutation-toolview', + inject: ['slots', 'conversation'], + /** + * Register the file-mutation row into the chat view's keyed toolview hole + * under both mutation tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) + }, +} diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx new file mode 100644 index 0000000000..77b762c38b --- /dev/null +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +// The diff render intent on the web side: the pure diffCardModel derivation +// over callView/resultView, and both conversation render sites that consume it +// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and +// the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { FileMutationRow } from '../src/client/toolviews/file-mutation-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' + +/** The edit tool's own call view (a call-time diff derived from the arguments). */ +const callDiff = (over?: Partial>): ToolCallView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +/** The edit tool's own result view (the applied hunk diff). */ +const resultDiff = (over?: Partial>): ToolResultView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'edit', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'edit', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, + callView: callDiff(), resultView: resultDiff(), ...over, +}) + +describe('diffCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(diffCardModel(running())).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + }) + }) + + it('derives a settled card from the result view, which replaces the call-time diff', () => { + // The applied hunks (result) win over the args-derived call diff. + expect(diffCardModel(settled({ + resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }), + }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, + }) + }) + + it('renders a settled diff even when the window dropped the call head', () => { + // A truncated call carries only the result view, which holds the whole change. + expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1) + }) + + it('returns null for every non-diff call: no views, generic views, unknown cards', () => { + expect(diffCardModel(running({ callView: null }))).toBeNull() + expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a diff call on the generic path (write/edit's + // own execution-error arm). + expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(diffCardModel(running({ callView: future }))).toBeNull() + expect(diffCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) +}) + +describe('chat row diff body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({ + callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), + }) + + it('the expanded body is the applied diff, capped tighter than the panel', () => { + expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the summary row (path) only, no diff body. + expect(view.queryByText('hello fixture')).toBeNull() + // The path link is not the expand control; the leading toggle is. + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call expands to its intended change', () => { + const view = render() + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + }) + + it('a non-diff call keeps the args-JSON text body', () => { + // A non-file tool name so the row is not single-file (no path link), and its + // args body is the fallback the diff card must not have replaced. + const view = render() + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText(/"foo"/)).toBeTruthy() + }) +}) + +describe('FileMutationRow diff card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the applied diff under the summary row, without an expand gesture', () => { + const view = render() + // The diff card is resident (no expand toggle needed). + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + expect(view.getByText('复制')).toBeTruthy() + }) + + it('the summary is a path link that opens through the host, cwd-resolved', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) + expect(openFile).toHaveBeenCalledWith('/w/app/notes/demo.txt') + }) + + it('registers under write too, rendering a create as an added-only diff', () => { + const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}' + const view = render() + expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('reflects the run state on its leading slot', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('a mutation call with no diff view renders the summary row alone', () => { + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + }) +}) + +describe('DetailsPanel diff Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } + + it('renders the applied diff at full height, keeping the JSON Input section', () => { + const view = mount(snapshot({ nodes: [settled()] }), target) + expect(view.getByText(/"file_path"/)).toBeTruthy() + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call renders its intended change, not the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.queryByText('运行中…')).toBeNull() + }) + + it('a non-diff result keeps the flattened pre', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'text', text: 'permission denied' }], + })], + }), target) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('Output').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied') + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..ba6010f6c7 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: 58c8ddcf0343216979ffdae7749c5368e26c45e5 +README.zh.md: 2d775f6591d3e2f5305cd517a38effb2cf25becf diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..58c8ddcf03 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and DiffBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Diff rendering + +`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..2d775f6591 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock,以及 DiffBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## Diff 渲染 + +`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css new file mode 100644 index 0000000000..8794dbb87e --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -0,0 +1,103 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface + + banner row, markdown code-block font) so a diff card reads as one family with + a fenced block and a terminal card. The deliberate divergence, shared with + TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally, + because folding a source line destroys the indentation a diff is read by. */ + +.block { + --dsl-diff-radius: 12px; + --dsl-diff-line-height: 22px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-diff-radius); +} + +/* The copy control floats in the top-right corner over the body, so the card + has no empty banner row above its first diff line (the TUI diff card has no + banner either — only the footer). The block is position: relative, so this + anchors to the card. */ +.copyButton { + position: absolute; + top: 8px; + right: 12px; + z-index: 1; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 12px 14px; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* No wrapping, no word-break: a diff is read by its indentation. */ +.line { + min-height: var(--dsl-diff-line-height); + white-space: pre; +} + +/* A file header: the path in the primary tone, set apart by weight. */ +.path { + color: var(--dsw-alias-label-primary); + font-weight: 600; +} + +/* A same-file second hunk's separator (a scattered edit), in the dim tone. */ +.gap { + color: var(--dsw-alias-label-tertiary); +} + +/* The diff's own meaning-carrying colors: removed on the error token, added on + the success token. A `- `/`+ ` prefix is drawn here so a copied line and the + shown line agree, and so the sign reads without relying on color alone. */ +.del::before { + content: '- '; + color: var(--dsw-alias-state-error-primary); +} + +.del { + color: var(--dsw-alias-state-error-primary); +} + +.add::before { + content: '+ '; + color: var(--dsw-alias-state-success-primary); +} + +.add { + color: var(--dsw-alias-state-success-primary); +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same + footer the TUI transcript's diff card draws. */ +.footer { + padding: 0 14px 12px; + font: var(--dsw-font-markdown-code-block); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx new file mode 100644 index 0000000000..ab1700b7b5 --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -0,0 +1,171 @@ +// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy +// control over one or more per-file hunks, each a bold path header followed by +// the removed block (`-`, error color) and the added block (`+`, success +// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors +// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads +// the same across front ends: the removed side is the old text in full, the +// added side the new text in full. Output never soft-wraps — an aligned source +// line keeps its indentation and scrolls horizontally instead of folding. +// Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. + +import { useCallback, useMemo, useState } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import css from './DiffBlock.module.css' + +/** + * Output lines shown before the height cap collapses the middle. Matches + * {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a + * long body at the same place. + */ +export const DEFAULT_DIFF_MAX_LINES = 16 + +/** + * One file's change, in the shape {@link DiffBlock} draws. Structurally the + * render-intent contract's `FileDiff`, redeclared here so this primitive stays + * free of the tool contract (the terminal card's decoupling, applied to diffs). + */ +export interface DiffHunk { + /** The changed file's path (as the tool operated on it; the bridge relativizes it). */ + path: string + /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */ + oldText: string | null + /** Content after the change (the added side). */ + newText: string +} + +export interface DiffBlockProps { + /** One entry per applied hunk, in file order; empty renders nothing. */ + diffs: DiffHunk[] + /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A single rendered body line and its role, so the height cap slices a flat list. */ +interface DiffRow { + kind: 'path' | 'del' | 'add' | 'gap' + text: string +} + +/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */ +const ROW_CLASS: Record = { + path: css.path, + del: css.del, + add: css.add, + gap: css.gap, +} + +/** + * Flatten the hunks into the body's rows plus the footer counts. A path header + * opens each new file; a same-file second hunk (a scattered edit) opens with a + * `⋯` gap instead of repeating the path. Every old-side line counts toward + * `removed` and every new-side line toward `added`, the same per-side line count + * the TUI footer draws, so the two front ends agree on a change's size. + * @param diffs - the hunks to render. + * @returns the body rows, the +/- totals, and the distinct-file count. + */ +function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } { + const rows: DiffRow[] = [] + const paths = new Set() + let added = 0 + let removed = 0 + let prevPath: string | undefined + for (const diff of diffs) { + paths.add(diff.path) + if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path }) + else rows.push({ kind: 'gap', text: '⋯' }) + prevPath = diff.path + if (diff.oldText !== null) { + for (const line of diff.oldText.split('\n')) { + rows.push({ kind: 'del', text: line }) + removed++ + } + } + for (const line of diff.newText.split('\n')) { + rows.push({ kind: 'add', text: line }) + added++ + } + } + return { rows, added, removed, files: paths.size } +} + +/** + * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its + * content, exactly what the card shows. The removed and added blocks are the + * change; the path headers keep a multi-file copy attributable. + * @param rows - the flattened body rows. + * @returns the diff as plain text. + */ +function copyText(rows: DiffRow[]): string { + return rows.map((row) => { + switch (row.kind) { + case 'del': return `- ${row.text}` + case 'add': return `+ ${row.text}` + case 'gap': return row.text + default: return row.text + } + }).join('\n') +} + +/** + * Render a file mutation as an inline diff surface. + * @param props - see {@link DiffBlockProps}. + * @returns the diff block element. + */ +export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { + const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(copyText(rows)).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, rows]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + if (rows.length === 0) return null + + const hidden = rows.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock and the TUI transcript's collapsed + // card, so a body's head and tail slices agree across the front ends. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + const head = capped ? rows.slice(0, headLines) : rows + const tail = capped ? rows.slice(rows.length - tailLines) : [] + + return ( +
+ +
+ {head.map((row, index) => ( +
{row.text}
+ ))} + {hidden > 0 && ( + + )} + {tail.map((row, index) => ( +
{row.text}
+ ))} +
+
└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..fc0d08b76e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' +export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.spec.tsx new file mode 100644 index 0000000000..d732a13315 --- /dev/null +++ b/packages/client/ui-primitives/tests/diff-block.spec.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +// DiffBlock: the per-file hunk rows (path header, removed block, added block), +// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and +// its singular/plural, the head/tail height cap and its expand control, the +// empty-diffs null render, and the copy control writing the prefixed diff text +// on both the accepted and the refused clipboard paths. writeClipboard's own +// return contract is pinned in terminal-block.spec.tsx (the shared seam), so +// only its DOM consequence is asserted here. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered body rows, one string per visible line (CSS-module class prefix). */ +function bodyRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '') +} + +/** Only the changed rows (add/del), excluding the path header and gap chrome. */ +function changeRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered added lines as one hunk's newText. */ +function added(count: number): string { + return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n') +} + +describe('DiffBlock structure', () => { + it('renders a create as a path header and an added block (no removed side)', () => { + const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }] + const { container } = render() + expect(screen.getByText('notes/new.txt')).toBeTruthy() + // No removed rows: both change lines are added. + expect(changeRows(container)).toEqual(['hello', 'world']) + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2) + }) + + it('renders an edit as a removed block above an added block', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }] + const { container } = render() + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1) + expect(changeRows(container)).toEqual(['old', 'new']) + }) + + it('opens a same-file second hunk with a gap instead of repeating the path', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + // One path header, one gap row. + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1) + }) + + it('opens a new file with its own path header', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'b.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0) + }) + + it('renders nothing for empty diffs', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) + +describe('DiffBlock footer', () => { + it('counts added and removed lines and one file', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }] + render() + expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy() + }) + + it('pluralizes the distinct-file count', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: null, newText: 'x' }, + { path: 'b.ts', oldText: null, newText: 'y' }, + ] + render() + expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy() + }) +}) + +describe('DiffBlock height cap', () => { + it('shows head and tail with an expand control past the cap, then all lines expanded', () => { + // One added line over the default cap forces the collapse. + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }] + // The path header counts as a row, so a body of maxLines added lines plus + // the header is one over the cap. + const { container } = render() + const toggle = screen.getByRole('button', { name: /展开其余/ }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + // Collapsed shows fewer rows than the full body. + const collapsedCount = bodyRows(container).length + expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1) + fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true') + expect(bodyRows(container).length).toBeGreaterThan(collapsedCount) + }) + + it('shows no expand control at or under the cap', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }] + render() + expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull() + }) +}) + +describe('DiffBlock copy', () => { + it('copies the prefixed diff text and flips the label on success', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'old', newText: 'new' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + // Path header, del/add prefixes, and the same-file gap all reach the clipboard. + expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q') + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('keeps the label on a refused clipboard write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('ignores a second click while the copied label is showing', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) }) + expect(writeText).toHaveBeenCalledTimes(1) + }) +}) From d7e46bea355d6246cfc9e0c3bd2c8696b7464da8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 16:45:00 +0800 Subject: [PATCH 24/85] test(web): update chat-apply keyed-entry assertion for the file-mutation rows The diff card registers edit and write into the keyed toolview hole, so the mounted-entry set is now ['bash', 'edit', 'write', 'todo_write']. --- .../client/ui-conversation/tests/chat-apply.spec.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..33f8af938d 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the file-mutation rows, and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // Each registrant plugin's inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // file-mutation registrant claims both write and edit for the diff card. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() From 4d3e324467e2722da7c01d327200c3393d171e18 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 16:47:27 +0800 Subject: [PATCH 25/85] docs: preserve established README voice --- ...-07-22-product-first-root-readme.i18n.yaml | 4 +- .../2026-07-22-product-first-root-readme.md | 18 ++- ...2026-07-22-product-first-root-readme.zh.md | 18 ++- README.i18n.yaml | 4 +- README.md | 110 ++++++++++++------ README.zh.md | 98 +++++++++++----- .../request-response.expected.json | 4 +- 7 files changed, 162 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index bd424e628d..92ca6f87e6 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: bed7d3b49c58a59fa0f6d6637c9852b9fb615b63 -2026-07-22-product-first-root-readme.zh.md: fbfb726b2c1a821b323de2fbf419dc97920a6878 +2026-07-22-product-first-root-readme.md: eeef25702b9d1ce87353d620e52ae661455d9b99 +2026-07-22-product-first-root-readme.zh.md: c4026484132772e3783492aaa0e368027ae15574 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index bed7d3b49c..eeef25702b 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -6,30 +6,28 @@ English | [中文](2026-07-22-product-first-root-readme.zh.md) ## Problem -The root README is the repository's product front door, but a product-only coding-agent description hides the SDK and current runtime breadth, while an SDK-first package inventory delays the shortest path to a working agent. Commands, capability claims, and entry-point descriptions also drift when the README is treated as general marketing instead of a maintained product contract. +The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. ## Decision -The root README defines DeepSeek Harness as a plugin-native coding-agent runtime that ships both a composable SDK and the assembled `dsh` agent. It separates mission from shipped facts and leads with the supported one-line installer. +The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page. -A note before the installer thanks early users, states plainly that the internal preview remains unfinished, has a low overall level of completion, and falls below the experience the team wants to deliver. It invites direct reports of failures, confusion, and friction, assigning those shortcomings to the product rather than the user, while the adjacent pre-release warning keeps the compatibility boundary explicit. +A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The technical pre-release statement remains in its existing development position. -The README names the TUI, Web, headless, ACP, and Python/JSON-RPC entry points with commands or owning links. It summarizes capabilities by coding, orchestration, and operational families, while stating that each composition selects its own plugins. Exhaustive package and service inventories stay in the generated graphs and package-group documentation. +The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. -Plugin-native is the organizing principle rather than a slogan: the README ties replaceable services and typed events to composition through `cordis.yml`, and ties model-visible behavior, persistence, replay, queries, telemetry, and UI projections to the authoritative session log. Detailed contracts remain with the architecture, CLI, examples, cookbook, and generated catalogs. - -The English and Chinese README sides share the same technical structure. Their community sections point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page; the repository README is not added to that projection. +Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page. ## Alternatives considered -**Present only the assembled coding agent.** This gives the shortest product pitch but makes the SDK, alternate front doors, and replaceable runtime seams look incidental even though they are shipped repository surfaces. +**Rewrite the README around a new product narrative.** A complete rewrite can make every current surface prominent, but it replaces accurate, reviewed copy and creates unnecessary churn. Current facts fit the established product-first structure. **Present the repository as an SDK and package catalog.** This exposes implementation breadth immediately but makes a new reader reconstruct the product from package names. The package map and generated capability graph remain the authoritative inventories. **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's developer/product front door have different navigation and maintenance needs. They remain separate sources linked to the same architecture and guides. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. ## Consequences -A new reader can install or choose a runtime surface before learning the package topology, while an SDK reader can see the extension model without a generated catalog being copied into prose. The README must change with any affected command, entry point, pre-release boundary, or high-level capability family, and each claim remains reviewable against source or an owning document. +Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, pre-release boundaries, or high-level capability families, while exhaustive detail remains linked rather than copied. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index fbfb726b2c..c402648413 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -6,30 +6,28 @@ Status: implemented ## 问题 -根 README 是仓库的产品入口,但仅以产品视角描述 coding agent(编程智能体)会掩盖 SDK 与当前运行时的广度,而以 SDK 为先的包(package)清单则会把启动可运行 agent 的最短路径放到后面。如果把 README 当作通用营销文案,而非持续维护的产品契约,命令、能力声明和入口描述也会逐渐漂移。 +根 README 是仓库的产品入口。其产品优先的结构和既有语气仍然有效,但随着运行时不断扩展,具体入口和能力声明会逐渐陈旧。重写事实仍然正确的章节,会扩大评审范围,也会丢弃已经行之有效的措辞。 ## 决策 -根 README 将 DeepSeek Harness 定义为以插件为原生构成单元的 coding agent 运行时,同时交付可组合的 SDK 与组装完成的 `dsh` agent。它将使命定位与已交付事实分开,并首先给出受支持的单行安装命令。 +只要背后的事实仍然正确,根 README 就保留既有结构、顺序和措辞。刷新时只修正陈旧声明,并补充呈现已交付内容所需的信息;不会因为仓库规模增长就重构整篇叙事。 -安装命令之前的一则说明感谢早期用户,坦率说明内测版本仍未完成、整体完成度还很低,距离团队希望交付的体验还有差距,并邀请用户直接反馈故障、困惑和所有不顺手之处。它明确这些不足是产品的问题,而非用户的问题;紧接其后的预发布提醒则清楚说明兼容性边界。 +安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。技术性的预发布声明仍保留在原有的开发章节位置。 -README 列出 TUI、Web、Headless、ACP(Agent Client Protocol)以及 Python/JSON-RPC 入口,并为每个入口提供命令或归属文档链接。它按编码、编排和运维三个类别概述能力,同时说明每种组合都自行选择插件。包与服务的完整清单仍由生成图和包分组文档维护。 +用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 -以插件为原生构成单元是 README 的组织原则,而非一句口号:README 通过 `cordis.yml` 将可替换服务、类型化事件与组合方式关联起来,并明确面向模型的行为、持久化、回放、查询、遥测和 UI 投影都以权威会话日志为基础。详细契约仍由架构文档、CLI(命令行界面)、示例、实操手册(cookbook)和生成目录各自维护。 - -中英文 README 采用相同的技术结构。两侧的社区章节分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页;仓库 README 不加入该投影。 +包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 ## 考虑过的替代方案 -**只展示组装完成的 coding agent。** 这样能给出最简短的产品介绍,但会让 SDK、其他入口和可替换的运行时 seam 显得无足轻重,尽管它们都是仓库中已经交付的组成部分。 +**围绕新的产品叙事重写 README。** 完整重写能够突出所有现有入口和能力,但也会替换准确且已经过评审的文案,造成不必要的变动。现有事实能够纳入既有的产品优先结构。 **将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。 **使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。 -**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向开发者和产品的入口在导航和维护需求上并不相同。两者继续作为独立来源,并链接到相同的架构文档和指南。 +**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。 ## 结果 -新读者可以在了解包拓扑之前完成安装或选择运行时入口,SDK 读者也能理解扩展模型,而无需把生成目录复制进正文。任何受影响的命令、入口、预发布边界或高层能力类别发生变化时,README 都必须同步更新;每项声明都可以依据源码或归属文档进行评审核验。 +评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、预发布边界或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 7bf728a630..5dd6ee3ce5 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 169722e07c9505c457a0ab7dbdca4e22f1178c92 -README.zh.md: d67e21daa58d203c42eab5e01414e688f2c8d1d3 +README.md: ad76366274086248cb4ea0be786ccc6fd0a51296 +README.zh.md: ae6244743fa2af136be17ea8e2b386fe6b1ec48d diff --git a/README.md b/README.md index 169722e07c..ad76366274 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,102 @@ English | [中文](README.zh.md) -DeepSeek Harness is an open-source, plugin-native runtime for coding agents. This repository ships both the composable SDK and `dsh`, a working agent assembled from the same packages. +DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK. -**Mission.** Build capable agent products without hard-wiring product choices into one loop. Models, tools, policy, storage, context, interfaces, and even the loop are [Cordis plugins](docs/architecture.md); the session log is the authoritative record from which model history, persistence, replay, queries, telemetry, and UIs derive. +It uses an architecture where **everything is a plugin**. ## Before you begin, thank you -Thank you for taking the time to try DeepSeek Harness. +Thank you for making time to try DeepSeek Harness. -This version is for internal testing only. Overall, it is still far from complete and nowhere near what we want to deliver. Some features are unfinished, and some parts will feel rough. What we learn from real use may also lead us to rethink the designs we have today. +This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough. -We will keep working carefully on it, and we sincerely want direct feedback—especially about the moments when it fails, confuses you, or gets in your way. If it does not help you, or makes your work harder, please leave a message in our WeCom group and tell us plainly. +“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs. -> **Pre-release notice:** Package APIs, configuration, and persisted formats may change without compatibility shims until the first tagged release. +We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it. -## Start in one command +## Install + +Install `dsh` with one command: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm`, prompts for a DeepSeek API key, and launches the TUI in the current directory. It keeps managed checkouts under `~/.dsh/source`; run the same command again to update. [`scripts/install.sh`](scripts/install.sh) documents alternate locations and non-interactive options. +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. -## Choose a surface +The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. -| Surface | Entry point | -|---|---| -| Full-screen TUI | `dsh` | -| Browser UI | `pnpm run demo:web` from a source checkout, or `dsh web` from a built checkout | -| One-shot headless task | `pnpm run demo:headless "summarize this workspace"`, or `dsh -p "summarize this workspace"` from a built checkout | -| ACP automation server | `pnpm run demo:acp` from a source checkout | -| Python / JSON-RPC SDK | [`python/`](python/README.md) with its bundled runtime | +## Use DeepSeek Harness -The one-line installer launches the source-running TUI without a build. The `dsh web` and `dsh -p` entries additionally need the frontend and client bundles from `pnpm run build`; `pnpm run demo:web` performs that build itself. The TUI, Web, and headless entries use the invoking directory as the workspace. See the [`dsh` CLI contract](apps/cli/README.md) for configuration, resume, provider, and workspace details; the [examples](examples/README.md) show the thinner ACP, JSON-RPC, Code Mode, and self-referential compositions. +### Web UI -## What ships - -Capabilities are selected by composition. The repository's shipped plugins cover: - -- **Coding:** filesystem read/write/edit and search, shell and persistent PTY execution, LSP navigation, web search/fetch, reusable skills, and model-written Code Mode programs. -- **Orchestration:** subagents, background tasks, worker-thread workflows, same-session goals, plan state, todos, and user questions. -- **Operations:** workspace sandboxing and approvals, session persistence/resume/fork/query, compaction and spill, projections, titles, and OpenTelemetry export. - -Anything visible to the model must be reconstructable from the session log. That makes alternate UIs, persistence backends, replay, and operational tooling consumers of one event stream instead of parallel sources of truth. - -## Extend the harness - -A swappable capability normally separates its interface, implementation, and consumer. Add or replace a provider behind a service such as `ctx.llm`, `ctx.fs`, `ctx.pty`, `ctx.web`, or `ctx.subagents`; register model-facing behavior through `ctx.tools`; attach policy and request shaping through typed events; compose the result in `cordis.yml` without forking the agent loop. - -Start with the [first-plugin guide](docs/user/develop/basic/index.md) and [extension cookbook](docs/cookbook/extension-cookbook.md). Use the [architecture](docs/architecture.md) for the system map, the generated [capability graph](docs/capability-seams.md) for current service relationships, and the [package map](packages/README.md) when you need ownership details. - -## Develop +For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink): ```sh -pnpm install -pnpm run demo:tui +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web +dsh web ``` -Set `DEEPSEEK_API_KEY` in the environment or root `.env`. The [development guide](docs/development.md) owns setup and validation; read the [architecture](docs/architecture.md) before changing `packages/`, and follow [AGENTS.md](AGENTS.md) when working in this repository. +The Web UI is served at `http://127.0.0.1:3080` by default. + +### TUI + +Start the full-screen terminal interface: + +```sh +dsh +``` + +### Headless + +Run one task, print the final answer, and exit: + +```sh +dsh -p "summarize this workspace" +``` + +### Automation and SDKs + +From a source checkout, start the ACP automation server: + +```sh +pnpm run demo:acp +``` + +The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions. + +## Why DeepSeek Harness + +Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode. + +- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. +- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log). +- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode). +- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md). ## Community -Follow DeepSeek Harness on X for project updates. +Follow DeepSeek Harness on Twitter for project updates. + +## Development + +```sh +pnpm install +pnpm run test:coverage +``` + +Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages. + +For agents, follow [AGENTS.md](AGENTS.md). + +DeepSeek Harness is currently pre-release. ## License diff --git a/README.zh.md b/README.zh.md index d67e21daa5..ae6244743f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,68 +2,106 @@ [English](README.md) | 中文 -DeepSeek Harness 是一个面向 coding agent(智能体)的开源、插件原生运行时。本仓库同时提供可组合的 SDK,以及由同一组包(package)组装而成、可直接运行的 agent `dsh`。 +DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。 -**使命。** 构建能力强大的 agent 产品,而不把产品选择硬编码到单一循环中。模型、工具、策略、存储、上下文、接口,乃至循环本身,都是 [Cordis 插件](docs/architecture.md);会话日志是权威记录,模型历史、持久化、回放、查询、遥测和 UI 均从中派生。 +它采用了**一切皆插件**的架构。 ## 使用前,想先说声谢谢 -感谢你愿意花时间试用 DeepSeek Harness。 +感谢您愿意拨冗试用 DeepSeek Harness。 -目前版本仅供内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。 +目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 -我们会继续认真打磨,也真诚希望听到直接的反馈——尤其是那些失败、困惑或不顺手的时刻。如果它没有帮到你,或者反而给工作添了麻烦,可以在企业微信群中留言,向我们反馈。 +“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。 -> **预发布说明:** 在首个带标签的版本发布之前,包 API、配置和持久化格式可能直接变更,不提供兼容层。 +我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 -## 一条命令开始 +## 安装 + +使用一条命令安装 `dsh`: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,可代为安装 `pnpm`,会提示输入 DeepSeek API 密钥,并在当前目录启动 TUI。它把受管检出放在 `~/.dsh/source` 下;再次运行同一命令即可更新。其他安装位置和非交互选项见 [`scripts/install.sh`](scripts/install.sh)。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 -## 选择使用方式 +安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 -| 使用方式 | 入口 | -|---|---| -| 全屏 TUI | `dsh` | -| 浏览器 UI | 在源码检出中运行 `pnpm run demo:web`,或在已构建的检出中运行 `dsh web` | -| 一次性无头任务 | 运行 `pnpm run demo:headless "summarize this workspace"`,或在已构建的检出中运行 `dsh -p "summarize this workspace"` | -| ACP(Agent Client Protocol)自动化服务器 | 在源码检出中运行 `pnpm run demo:acp` | -| Python / JSON-RPC SDK | [`python/`](python/README.md) 及其自带的运行时 | +## 使用 DeepSeek Harness -一行安装命令可直接启动从源码运行的 TUI,无需构建。`dsh web` 和 `dsh -p` 入口还需要先通过 `pnpm run build` 生成前端与客户端构建产物;`pnpm run demo:web` 会自行执行该构建。TUI、Web 和无头入口都把调用命令时的目录用作工作区。配置、会话恢复、提供方及工作区细节见 [`dsh` CLI(命令行界面)契约](apps/cli/README.md);[示例](examples/README.md)展示了更精简的 ACP、JSON-RPC、Code Mode 和自指组合。 +### Web UI -## 当前提供的能力 +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析): -能力由组合决定。本仓库交付的插件涵盖: +```sh +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web +dsh web +``` -- **编程:** 文件系统读写、编辑与搜索,shell 和持久 PTY 执行,LSP 导航,Web 搜索与抓取,可复用 skill(技能),以及由模型编写的 Code Mode 程序。 -- **编排:** subagent、后台任务、工作线程工作流、同一会话内的目标、计划状态、待办事项,以及向用户提问。 -- **运维:** 工作区沙箱与审批、会话持久化/恢复/fork/查询、压缩(compaction)与 spill、投影、标题,以及 OpenTelemetry 导出。 +Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 -凡是模型可见的内容,都必须能从会话日志中重建。这样一来,不同 UI、持久化后端、回放和运维工具都成为同一事件流的消费方,而不是彼此并行的真源。 +### TUI -## 扩展 harness +启动全屏终端界面: -一项可替换能力通常会将接口、实现和消费方彼此分离。你可以为 `ctx.llm`、`ctx.fs`、`ctx.pty`、`ctx.web` 或 `ctx.subagents` 等服务添加或替换提供方;通过 `ctx.tools` 注册面向模型的行为;通过类型化事件挂接策略和请求整形;再在 `cordis.yml` 中组合这些部分,无需 fork agent loop(智能体循环)。 +```sh +dsh +``` -从[第一个插件指南](docs/user/develop/basic/index.md)和[扩展实操手册](docs/cookbook/extension-cookbook.md)开始。需要系统图时查看[架构](docs/architecture.md),需要当前服务关系时查看生成的[能力图](docs/capability-seams.md),需要所有权细节时查看[包图](packages/README.md)。 +### Headless + +运行一项任务,打印最终答案后退出: + +```sh +dsh -p "summarize this workspace" +``` + +### 自动化与 SDK + +从源码检出中启动 ACP(Agent Client Protocol)自动化服务器: + +```sh +pnpm run demo:acp +``` + +[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。 + +## 为什么选择 DeepSeek Harness + +内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。 + +- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 +- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。 +- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。 +- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。 + +## 社区 + +扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。 + +

+ DeepSeek Harness 微信社区二维码 +

## 开发 ```sh pnpm install -pnpm run demo:tui +pnpm run test:coverage ``` -将 `DEEPSEEK_API_KEY` 设置在环境变量或根目录 `.env` 中。环境搭建和验证由[开发指南](docs/development.md)统一说明;修改 `packages/` 前请阅读[架构](docs/architecture.md),在本仓库工作时请遵循 [AGENTS.md](AGENTS.md)。 +请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。 -## 社区 +面向 agent:遵循 [AGENTS.md](AGENTS.md)。 -前往 DeepSeek Harness 微信社区关注项目动态。 +DeepSeek Harness 目前处于预发布阶段。 ## 许可证 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index c1928b047d..627725f4b7 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness is an open-source, plugin-native runtime for coding agents. This repository ships both the composable SDK and `dsh`, a working agent assembled from the same packages.\n\n**Mission.** Build capable agent products without hard-wiring product choices into one loop. Models, tools, policy, storage, context, interfaces, and even the loop are [Cordis plugins](docs/architecture.md); the session log is the authoritative record from which model history, persistence, replay, queries, telemetry, and UIs derive.\n\n## Before you begin, thank you\n\nThank you for taking the time to try DeepSeek Harness.\n\nThis version is for internal testing only. Overall, it is still far from complete and nowhere near what we want to deliver. Some features are unfinished, and some parts will feel rough. What we learn from real use may also lead us to rethink the designs we have today.\n\nWe will keep working carefully on it, and we sincerely want direct feedback—especially about the moments when it fails, confuses you, or gets in your way. If it does not help you, or makes your work harder, please leave a message in our WeCom group and tell us plainly.\n\n> **Pre-release notice:** Package APIs, configuration, and persisted formats may change without compatibility shims until the first tagged release.\n\n## Start in one command\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm`, prompts for a DeepSeek API key, and launches the TUI in the current directory. It keeps managed checkouts under `~/.dsh/source`; run the same command again to update. [`scripts/install.sh`](scripts/install.sh) documents alternate locations and non-interactive options.\n\n## Choose a surface\n\n| Surface | Entry point |\n|---|---|\n| Full-screen TUI | `dsh` |\n| Browser UI | `pnpm run demo:web` from a source checkout, or `dsh web` from a built checkout |\n| One-shot headless task | `pnpm run demo:headless \"summarize this workspace\"`, or `dsh -p \"summarize this workspace\"` from a built checkout |\n| ACP automation server | `pnpm run demo:acp` from a source checkout |\n| Python / JSON-RPC SDK | [`python/`](python/README.md) with its bundled runtime |\n\nThe one-line installer launches the source-running TUI without a build. The `dsh web` and `dsh -p` entries additionally need the frontend and client bundles from `pnpm run build`; `pnpm run demo:web` performs that build itself. The TUI, Web, and headless entries use the invoking directory as the workspace. See the [`dsh` CLI contract](apps/cli/README.md) for configuration, resume, provider, and workspace details; the [examples](examples/README.md) show the thinner ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## What ships\n\nCapabilities are selected by composition. The repository's shipped plugins cover:\n\n- **Coding:** filesystem read/write/edit and search, shell and persistent PTY execution, LSP navigation, web search/fetch, reusable skills, and model-written Code Mode programs.\n- **Orchestration:** subagents, background tasks, worker-thread workflows, same-session goals, plan state, todos, and user questions.\n- **Operations:** workspace sandboxing and approvals, session persistence/resume/fork/query, compaction and spill, projections, titles, and OpenTelemetry export.\n\nAnything visible to the model must be reconstructable from the session log. That makes alternate UIs, persistence backends, replay, and operational tooling consumers of one event stream instead of parallel sources of truth.\n\n## Extend the harness\n\nA swappable capability normally separates its interface, implementation, and consumer. Add or replace a provider behind a service such as `ctx.llm`, `ctx.fs`, `ctx.pty`, `ctx.web`, or `ctx.subagents`; register model-facing behavior through `ctx.tools`; attach policy and request shaping through typed events; compose the result in `cordis.yml` without forking the agent loop.\n\nStart with the [first-plugin guide](docs/user/develop/basic/index.md) and [extension cookbook](docs/cookbook/extension-cookbook.md). Use the [architecture](docs/architecture.md) for the system map, the generated [capability graph](docs/capability-seams.md) for current service relationships, and the [package map](packages/README.md) when you need ownership details.\n\n## Develop\n\n```sh\npnpm install\npnpm run demo:tui\n```\n\nSet `DEEPSEEK_API_KEY` in the environment or root `.env`. The [development guide](docs/development.md) owns setup and validation; read the [architecture](docs/architecture.md) before changing `packages/`, and follow [AGENTS.md](AGENTS.md) when working in this repository.\n\n## Community\n\nFollow DeepSeek Harness on X for project updates.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Before you begin, thank you\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness 是一个面向 coding agent(智能体)的开源、插件原生运行时。本仓库同时提供可组合的 SDK,以及由同一组包(package)组装而成、可直接运行的 agent `dsh`。\n\n**使命。** 构建能力强大的 agent 产品,而不把产品选择硬编码到单一循环中。模型、工具、策略、存储、上下文、接口,乃至循环本身,都是 [Cordis 插件](docs/architecture.md);会话日志是权威记录,模型历史、持久化、回放、查询、遥测和 UI 均从中派生。\n\n## 使用前,想先说声谢谢\n\n感谢你愿意花时间试用 DeepSeek Harness。\n\n目前版本仅供内测,整体完成度不高,也远没有达到我们想交付的样子。有些功能还没做完,有些地方用起来会很粗糙。真实使用中暴露出来的问题,也可能让我们推翻现在的设计。\n\n我们会继续认真打磨,也真诚希望听到直接的反馈——尤其是那些失败、困惑或不顺手的时刻。如果它没有帮到你,或者反而给工作添了麻烦,可以在企业微信群中留言,向我们反馈。\n\n> **预发布说明:** 在首个带标签的版本发布之前,包 API、配置和持久化格式可能直接变更,不提供兼容层。\n\n## 一条命令开始\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,可代为安装 `pnpm`,会提示输入 DeepSeek API 密钥,并在当前目录启动 TUI。它把受管检出放在 `~/.dsh/source` 下;再次运行同一命令即可更新。其他安装位置和非交互选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 选择使用方式\n\n| 使用方式 | 入口 |\n|---|---|\n| 全屏 TUI | `dsh` |\n| 浏览器 UI | 在源码检出中运行 `pnpm run demo:web`,或在已构建的检出中运行 `dsh web` |\n| 一次性无头任务 | 运行 `pnpm run demo:headless \"summarize this workspace\"`,或在已构建的检出中运行 `dsh -p \"summarize this workspace\"` |\n| ACP(Agent Client Protocol)自动化服务器 | 在源码检出中运行 `pnpm run demo:acp` |\n| Python / JSON-RPC SDK | [`python/`](python/README.md) 及其自带的运行时 |\n\n一行安装命令可直接启动从源码运行的 TUI,无需构建。`dsh web` 和 `dsh -p` 入口还需要先通过 `pnpm run build` 生成前端与客户端构建产物;`pnpm run demo:web` 会自行执行该构建。TUI、Web 和无头入口都把调用命令时的目录用作工作区。配置、会话恢复、提供方及工作区细节见 [`dsh` CLI(命令行界面)契约](apps/cli/README.md);[示例](examples/README.md)展示了更精简的 ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 当前提供的能力\n\n能力由组合决定。本仓库交付的插件涵盖:\n\n- **编程:** 文件系统读写、编辑与搜索,shell 和持久 PTY 执行,LSP 导航,Web 搜索与抓取,可复用 skill(技能),以及由模型编写的 Code Mode 程序。\n- **编排:** subagent、后台任务、工作线程工作流、同一会话内的目标、计划状态、待办事项,以及向用户提问。\n- **运维:** 工作区沙箱与审批、会话持久化/恢复/fork/查询、压缩(compaction)与 spill、投影、标题,以及 OpenTelemetry 导出。\n\n凡是模型可见的内容,都必须能从会话日志中重建。这样一来,不同 UI、持久化后端、回放和运维工具都成为同一事件流的消费方,而不是彼此并行的真源。\n\n## 扩展 harness\n\n一项可替换能力通常会将接口、实现和消费方彼此分离。你可以为 `ctx.llm`、`ctx.fs`、`ctx.pty`、`ctx.web` 或 `ctx.subagents` 等服务添加或替换提供方;通过 `ctx.tools` 注册面向模型的行为;通过类型化事件挂接策略和请求整形;再在 `cordis.yml` 中组合这些部分,无需 fork agent loop(智能体循环)。\n\n从[第一个插件指南](docs/user/develop/basic/index.md)和[扩展实操手册](docs/cookbook/extension-cookbook.md)开始。需要系统图时查看[架构](docs/architecture.md),需要当前服务关系时查看生成的[能力图](docs/capability-seams.md),需要所有权细节时查看[包图](packages/README.md)。\n\n## 开发\n\n```sh\npnpm install\npnpm run demo:tui\n```\n\n将 `DEEPSEEK_API_KEY` 设置在环境变量或根目录 `.env` 中。环境搭建和验证由[开发指南](docs/development.md)统一说明;修改 `packages/` 前请阅读[架构](docs/architecture.md),在本仓库工作时请遵循 [AGENTS.md](AGENTS.md)。\n\n## 社区\n\n前往 DeepSeek Harness 微信社区关注项目动态。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 使用前,想先说声谢谢\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n从源码检出中启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", From 013761f85060fb3b05fb58339d1b101f534fb75d Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 17:17:22 +0800 Subject: [PATCH 26/85] docs: verify and simplify launch instructions --- ...026-07-22-product-first-root-readme.i18n.yaml | 4 ++-- .../2026-07-22-product-first-root-readme.md | 6 +++--- .../2026-07-22-product-first-root-readme.zh.md | 6 +++--- README.i18n.yaml | 4 ++-- README.md | 16 +++++----------- README.zh.md | 16 +++++----------- .../request-response.expected.json | 4 ++-- 7 files changed, 22 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index 92ca6f87e6..b38df9878c 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: eeef25702b9d1ce87353d620e52ae661455d9b99 -2026-07-22-product-first-root-readme.zh.md: c4026484132772e3783492aaa0e368027ae15574 +2026-07-22-product-first-root-readme.md: 34bee8210615f4c9b4a2a9389e6edd962850fdc5 +2026-07-22-product-first-root-readme.zh.md: be0c6189f5feef9b923f0081e224a7e042aef001 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index eeef25702b..34bee82106 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -12,9 +12,9 @@ The root README is the repository's product front door. Its product-first struct The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page. -A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The technical pre-release statement remains in its existing development position. +A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing. -The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. +The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command after real PTY validation; the Web instructions build the default active checkout once and then run `dsh web`, matching a production build and HTTP smoke. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page. @@ -30,4 +30,4 @@ Detailed package and service inventories remain at their owning documentation. T ## Consequences -Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, pre-release boundaries, or high-level capability families, while exhaustive detail remains linked rather than copied. +Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, release-stage claims, or high-level capability families, while exhaustive detail remains linked rather than copied. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index c402648413..be0c6189f5 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -12,9 +12,9 @@ Status: implemented 只要背后的事实仍然正确,根 README 就保留既有结构、顺序和措辞。刷新时只修正陈旧声明,并补充呈现已交付内容所需的信息;不会因为仓库规模增长就重构整篇叙事。 -安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。技术性的预发布声明仍保留在原有的开发章节位置。 +安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。既有的开发阶段声明明确说明 DeepSeek Harness 处于内测阶段。 -用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 +用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。经真实 PTY 验证,安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建一次默认活动检出,然后运行 `dsh web`,该路径已经过生产构建与 HTTP 冒烟验证。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 @@ -30,4 +30,4 @@ Status: implemented ## 结果 -评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、预发布边界或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。 +评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、发布阶段声明或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 5dd6ee3ce5..1575c6d5d3 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: ad76366274086248cb4ea0be786ccc6fd0a51296 -README.zh.md: ae6244743fa2af136be17ea8e2b386fe6b1ec48d +README.md: c89a7bad2b06a1d9aeaf74b9450c6362f8fbeb6b +README.zh.md: 85156ca3f6b40801da0f773601301f8cfe3d9c65 diff --git a/README.md b/README.md index ad76366274..c89a7bad2b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Ha It uses an architecture where **everything is a plugin**. -## Before you begin, thank you +## Internal testing notice Thank you for making time to try DeepSeek Harness. @@ -32,20 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~ ### Web UI -For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink): +For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI: ```sh -dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") -while [ -L "$dsh_bin" ]; do - link=$(readlink "$dsh_bin") - case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac -done -dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) -pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web +(cd ~/.dsh/source/current && pnpm run build) dsh web ``` -The Web UI is served at `http://127.0.0.1:3080` by default. +The build path above is the installer's default; see [`scripts/install.sh`](scripts/install.sh) for alternate locations. The Web UI is served at `http://127.0.0.1:3080` by default. ### TUI @@ -97,7 +91,7 @@ Start with the [development guide](docs/development.md) and read the [architectu For agents, follow [AGENTS.md](AGENTS.md). -DeepSeek Harness is currently pre-release. +DeepSeek Harness is currently in internal testing. ## License diff --git a/README.zh.md b/README.zh.md index ae6244743f..85156ca3f6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 它采用了**一切皆插件**的架构。 -## 使用前,想先说声谢谢 +## 内测声明 感谢您愿意拨冗试用 DeepSeek Harness。 @@ -32,20 +32,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m ### Web UI -推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析): +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI: ```sh -dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") -while [ -L "$dsh_bin" ]; do - link=$(readlink "$dsh_bin") - case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac -done -dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) -pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web +(cd ~/.dsh/source/current && pnpm run build) dsh web ``` -Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +上述构建路径使用安装器的默认安装位置;如需使用其他位置,请参阅 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 ### TUI @@ -101,7 +95,7 @@ pnpm run test:coverage 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 -DeepSeek Harness 目前处于预发布阶段。 +DeepSeek Harness 目前处于内测阶段。 ## 许可证 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 627725f4b7..d3fc01105b 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Before you begin, thank you\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe build path above is the installer's default; see [`scripts/install.sh`](scripts/install.sh) for alternate locations. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 使用前,想先说声谢谢\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n从源码检出中启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述构建路径使用安装器的默认安装位置;如需使用其他位置,请参阅 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n从源码检出中启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", From f6802ee0192243a6f704d3ac51d3abeede9d8d63 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:43:13 +0800 Subject: [PATCH 27/85] feat(web): render web_search/web_fetch output as a web card Consume the card:'web' result view (structured sources + answer for search, the URL and HTTP status for fetch) the web backend PR added. WebBlock (ui-primitives) draws both kinds via the kind discriminant: a citation list of safe external links (the MarkdownText protocol allowlist, title-or-hostname label), a truncation indicator, a height cap matching TerminalBlock; a fetch summary for the other kind. web-card-model is the single resultView derivation; a keyed WebRow registers under web_search and web_fetch with the card resident under its summary. The generic fallback and the details panel are web-aware. Fixture gains web_search and web_fetch turns for the built-boot snapshot. --- ...6-07-30-web-result-card-frontend.i18n.yaml | 6 + .../2026-07-30-web-result-card-frontend.md | 49 ++++ .../2026-07-30-web-result-card-frontend.zh.md | 49 ++++ .../client/connection/src/client/fixture.ts | 79 +++++- .../ui-conversation/src/client/apply.ts | 6 + .../client/chat/GenericToolCard.module.css | 15 ++ .../src/client/chat/GenericToolCard.tsx | 19 +- .../src/client/contract/web-card-model.ts | 70 +++++ .../client/skeleton/DetailsPanel.module.css | 6 + .../src/client/skeleton/DetailsPanel.tsx | 13 +- .../src/client/toolviews/web-row.module.css | 95 +++++++ .../src/client/toolviews/web-row.tsx | 95 +++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 9 +- .../ui-conversation/tests/web-card.spec.tsx | 255 ++++++++++++++++++ .../ui-primitives/src/WebBlock.module.css | 124 +++++++++ .../client/ui-primitives/src/WebBlock.tsx | 209 ++++++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../ui-primitives/tests/web-block.spec.tsx | 165 ++++++++++++ 18 files changed, 1256 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md create mode 100644 packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css create mode 100644 packages/client/ui-conversation/src/client/contract/web-card-model.ts create mode 100644 packages/client/ui-conversation/src/client/toolviews/web-row.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/web-row.tsx create mode 100644 packages/client/ui-conversation/tests/web-card.spec.tsx create mode 100644 packages/client/ui-primitives/src/WebBlock.module.css create mode 100644 packages/client/ui-primitives/src/WebBlock.tsx create mode 100644 packages/client/ui-primitives/tests/web-block.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml new file mode 100644 index 0000000000..e2cb16b5cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-30-web-result-card-frontend.md +2026-07-30-web-result-card-frontend.md: cf2dd26a8f6eebe6d8d5d275a462151b7c3274a2 +2026-07-30-web-result-card-frontend.zh.md: 505243f7a31df1a5be0381a9399c5283272d1fe8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md new file mode 100644 index 0000000000..cf2dd26a8f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -0,0 +1,49 @@ +# Agent Note: Web result card frontend — rendering the web render intent in the browser + +Status: implemented + +English | [中文](2026-07-30-web-result-card-frontend.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary. + +## Decision + +`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), and for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves). + +One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. + +**Links are safe by the same allowlist MarkdownText applies to untrusted assistant-authored links.** A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. + +**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. + +The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance. + +## Consequences + +`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free. + +A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change. + +## Alternatives considered + +**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic. + +**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it. + +**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist matches MarkdownText's, so untrusted links behave identically wherever they render. + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap. + +`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. + +The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. + +## Related + +- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md new file mode 100644 index 0000000000..505243f7a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图 + +Status: implemented + +[English](2026-07-30-web-result-card-frontend.md) | 中文 + +## Problem + +`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。 + +## Decision + +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、以及对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)。 + +一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 + +**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用的同一 allowlist。** 一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 + +**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 + +卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片。 + +## Consequences + +`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。 + +一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。 + +## Alternatives considered + +**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。 + +**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。 + +**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 与 MarkdownText 的一致,因此不受信任的链接无论在何处渲染都行为相同。 + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。 + +`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 + +fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 + +## Related + +- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。 +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..0196713372 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,6 +136,54 @@ const TERMINAL_EXIT_STATUS: Record = { @@ -29,8 +31,9 @@ const VARIANT_ICONS: Record = { export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const web = webCardModel(block) const singleFile = model.filePath !== undefined - return ( + const row = ( ) + // A web-declaring tool without its own keyed row lands here; its card is + // resident under the summary, mirroring WebRow (and BashRow's terminal card). + if (web === null) return row + return ( +
+ {row} + +
+ ) } diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts new file mode 100644 index 0000000000..28eb3de2d2 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -0,0 +1,70 @@ +/** + * Pure derivation of the web-card props from a frozen call slice: the + * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at + * result time arrives on the snapshot as `resultView`, and this is the one + * place that turns it into what {@link WebBlock} draws. Both conversation + * render sites (the chat tool row's resident/expanded body and the details + * panel's Output section) call this, so the sources and fetch summary they + * show are derived once. + * + * The web card is result-only by contract: those tools keep a generic pending + * call view, so there is nothing to derive while the call is still running and + * a running call always takes the generic path. + * @module + */ +import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Sources the chat row's web body shows before collapsing the middle — half + * the primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. A + * design constant of this UI's row geometry, not a deployment choice, so it is + * fixed here rather than a plugin Config field. + */ +export const CHAT_WEB_MAX_SOURCES = 8 + +/** + * Derive the web-card props for a tool call, or null when this call is not a + * web card and belongs on the generic path. + * + * The result side supplies the whole card: the sources and answer for a + * `search`, the URL and status for a `fetch`. Cases producing null, all of + * them the documented generic-card default: + * + * - A running call (no `resultView` yet): the web tools keep a generic pending + * card, so nothing web-shaped exists until the call settles. + * - A settled call whose result view is not a web card — including a `card` + * value this UI version does not know, which arrives over the wire and so + * cannot be trusted to be one of the compiled variants, and a generic result + * view (a web tool's error path returns the generic card, whose text the + * generic path preserves). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebBlockProps | null { + // Running calls have no result view; the web card is result-only. + if (!('kind' in block)) return null + const result = block.resultView + if (result?.card !== 'web') return null + if (result.kind === 'search') { + return { + kind: 'search', + answer: result.answer, + sources: result.sources.map(source => ({ + url: source.url, + title: source.title, + snippet: source.snippet, + publishedAt: source.publishedAt, + })), + truncated: result.truncated, + } + } + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..b4e85912ab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -106,3 +106,9 @@ .terminal { margin: 0; } + +/* Same rule for the web card: it sits under the section label, so the section + owns the spacing rather than the primitive's own vertical margin. */ +.web { + margin: 0; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..d31717da44 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,11 +7,12 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' +import { webCardModel } from '../contract/web-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A web-card call — a + * `web_search`/`web_fetch` result — renders through WebBlock at its own full + * source-list allowance. Every other call, and a running call with no card + * yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const web = webCardModel(material.block) + // Full source-list allowance here (the panel is the single-call reading + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. + if (web !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css new file mode 100644 index 0000000000..0b1519e218 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css @@ -0,0 +1,95 @@ +/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus + the web card the row stacks under its summary line, mirroring the bash row's + resident terminal card. */ + +/* Summary line over the web card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-web-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-web-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.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; + color: var(--dsw-alias-label-secondary); +} + +.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); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx new file mode 100644 index 0000000000..29a30776f9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -0,0 +1,95 @@ +// Web toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Registered under BOTH web_search and web_fetch, since both declare the one +// `web` render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. +// +// A web tool declares the `web` render intent at result time, so this row +// renders the completed retrieval through WebBlock resident below its summary, +// the same posture BashRow uses for the terminal card: no expand control on the +// row itself, not a details-panel target, and the block's own expander keeps a +// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is +// passed as maxSources — the chat flow's tighter cap over the block's default +// of 16). Until the call settles there is no web card (the tools keep a generic +// pending view), so a running row is the summary line alone. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './web-row.module.css' + +/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ +const WEB_TITLES: Record = { + web_search: 'Search', + web_fetch: 'Fetch', +} + +/** Leading icon per tool, yielding to the state semantic while failed/stopped. */ +function leadingFor(toolName: string, state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return toolName === 'web_fetch' ? : + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with + * the completed retrieval's web card resident below it. The summary row is not + * a details-panel control (tool rows stopped being one), so the card's own + * links and expander are the row's only interactions. + */ +export function WebRow({ toolName, block }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const web = webCardModel(block) + const status = stateStatus(model.state) + return ( +
+
+ {leadingFor(toolName, model.state)} + {status !== null && {status}} + {WEB_TITLES[toolName] ?? model.title} + + {model.summary} +
+ {web !== null && ( + + )} +
+ ) +} + +/** + * The web rows as a plain registrant plugin, riding the same load-order seam as + * the bash sample: `inject: ['conversation']` guarantees the chat entry (and + * with it the 'conversation.chat.toolview' declaration) is on the ledger. One + * WebRow component registers under both web tool names. + */ +export const webToolview = { + name: 'web-toolview', + inject: ['slots', 'conversation'], + /** + * Register the web row under both web tool names' keyed toolview holes. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..42d1f648a1 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the web rows, and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // web rows register one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx new file mode 100644 index 0000000000..9ed25978f1 --- /dev/null +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +// The web render intent on the web side: the pure webCardModel derivation over +// resultView, and the conversation render sites that consume it — the keyed +// WebRow (registered under both web_search and web_fetch), the GenericToolCard +// render-site fallback, and the details panel's Output section. Mirrors +// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat +// row's resident card, the panel arm, and the keyed registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const SEARCH_ARGS = '{"query":"deepseek harness"}' +const FETCH_ARGS = '{"url":"https://example.com/page"}' + +/** A web_search result view; overrides tune the sources / answer / truncation. */ +const resultSearch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'search', truncated: false, + answer: 'A short answer.', + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b' }, + ], + ...over, +}) + +/** A web_fetch result view. */ +const resultFetch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, +}) + +const runningSearch = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over, +}) + +const settledSearch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'web_search', argsRaw: SEARCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'search text' }], isError: false, + callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over, +}) + +const settledFetch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'fetch body' }], isError: false, + callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over, +}) + +describe('webCardModel', () => { + it('derives a search card from the result view, projecting every source field', () => { + expect(webCardModel(settledSearch())).toEqual({ + kind: 'search', + answer: 'A short answer.', + truncated: false, + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined }, + ], + }) + }) + + it('carries the search truncation flag and an absent answer', () => { + const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } })) + expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] }) + }) + + it('derives a fetch card from the result view', () => { + expect(webCardModel(settledFetch())).toEqual({ + kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, + }) + expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) }))) + .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true }) + }) + + it('returns null for a running call, since the web card is result-only', () => { + expect(webCardModel(runningSearch())).toBeNull() + // Even a running call that somehow carried a web call view stays generic: + // the derivation reads resultView only. + expect(webCardModel(runningSearch({ callView: null }))).toBeNull() + }) + + it('returns null for a settled call whose result view is not a web card', () => { + expect(webCardModel(settledSearch({ resultView: null }))).toBeNull() + expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + }) +}) + +describe('chat row web body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: block.callId, toolName, block, openFile: vi.fn(), + }) + // WebRow reads only toolName/block off the full runtime share; the standard + // kit is unused, so the cast supplies the owner slice alone (as BashRow's + // tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => + ownerProps(block, toolName) as unknown as ToolRowProps + + it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) + const view = render() + // The summary row plus the resident card, without any expand gesture on the row itself. + expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // hostname fallback for the source with no title + expect(view.getByText('plain.example.org')).toBeTruthy() + }) + + it('the WebRow renders the fetch card resident, titled Fetch', () => { + const view = render() + expect(view.getByText('Fetch')).toBeTruthy() + // The url shows in the summary row and as the card's link; scope to the card. + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('a running web call is the summary row alone (no card until it settles)', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.queryByText('Titled')).toBeNull() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) + + it('a failed web call keeps the summary row without the card', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('[data-web]')).toBeNull() + // The row reflects the error state so the summary line still reads as failed. + expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => { + // A web-declaring tool without its own keyed row lands on the fallback; its + // card is resident there too. + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { + const view = render() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) +}) + +describe('DetailsPanel web Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + it('renders the search card at full source allowance', () => { + const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // The Input JSON section survives beside it. + expect(view.getByText(/"query"/)).toBeTruthy() + }) + + it('renders the fetch card', () => { + const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('a non-web result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledSearch({ callView: null, resultView: null })], + }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + const output = view.getByText('Output').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('search text') + }) +}) + +describe('web toolview registration', () => { + it('registers one WebRow under both web_search and web_fetch', () => { + const registered: { key: string; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + return () => {} + }, + }, + } as unknown as import('cordis').Context + webToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // One component under both keys, not two thin rows. + expect(registered[0]?.component).toBe(WebRow) + expect(registered[1]?.component).toBe(WebRow) + // The load-order seam the render site depends on. + expect(webToolview.inject).toEqual(['slots', 'conversation']) + }) +}) diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css new file mode 100644 index 0000000000..d595acb3cc --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -0,0 +1,124 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface, + 16px vertical margin) so a web card, a terminal card, and a fenced code block + read as one family. A source list is prose, not aligned output, so it wraps + normally rather than scrolling horizontally like a terminal card's output. */ + +.block { + --dsl-web-radius: 12px; + + margin: 16px 0; + padding: 12px 14px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-web-radius); +} + +/* The provider answer reads as body prose above the citation list; its own + MarkdownText margins are trimmed so the list sits tight under it. */ +.answer { + margin-bottom: 8px; +} + +.answer > :global(div) > :first-child { + margin-top: 0; +} + +.answer > :global(div) > :last-child { + margin-bottom: 0; +} + +/* The citation list: ordered so each source reads as a numbered reference. */ +.sources { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.source { + min-width: 0; +} + +.sourceLink { + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + line-height: 20px; + word-break: break-word; +} + +.sourceLink:hover { + text-decoration: underline; +} + +.snippet { + margin-top: 2px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 19px; + word-break: break-word; +} + +.published { + margin-top: 2px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +.truncated { + margin-top: 8px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card is a compact summary: the URL over a status/truncation row. */ +.fetch { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fetchUrl { + color: var(--dsw-alias-state-business-primary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 19px; + word-break: break-all; +} + +.fetchUrl:hover { + text-decoration: underline; +} + +.fetchMeta { + display: flex; + align-items: baseline; + gap: 12px; +} + +.status { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card's truncation note sits inline beside the status, so it drops + the search card's top margin. */ +.fetch .truncated { + margin-top: 0; +} diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx new file mode 100644 index 0000000000..d4757858a3 --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -0,0 +1,209 @@ +// WebBlock: the surface for a completed web retrieval. One component draws both +// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an +// optional provider answer above a citation list of sources (each a safe +// external link labelled by its title, or its hostname when the provider gave +// none, with the snippet and publication date below it), and a `fetch` shows a +// compact retrieval summary (the linked final URL and its HTTP status). Both +// mark a capped retrieval. Every link is a same-origin-safe external anchor: +// only http(s) URLs become anchors (target/rel set), the same protocol allowlist +// MarkdownText applies to untrusted assistant-authored links; an unparseable or +// non-http URL renders as plain text. Geometry, radius, and fonts mirror +// CodeBlock/TerminalBlock so a web card reads as one family with them; a long +// source list caps at maxSources with a head/tail collapse using the same +// arithmetic as TerminalBlock's output cap. + +import { useCallback, useState } from 'react' +import clsx from 'clsx' +import { MarkdownText } from './markdown/MarkdownText.tsx' +import css from './WebBlock.module.css' + +/** + * Sources shown before the height cap collapses the middle of a citation list. + * Matches TerminalBlock's default output budget so both cards cut a long body + * at the same place; the chat row narrows it through the maxSources prop. + */ +export const DEFAULT_WEB_MAX_SOURCES = 16 + +/** + * One citeable source drawn in a search card: the projection of the contract's + * `WebSource`, with the optional fields kept optional so a provider that + * returned only a URL still renders (its hostname becomes the label). + */ +export interface WebSourceView { + /** The source URL; becomes a safe external link when it is http(s). */ + url: string + /** The source title; when absent the URL's hostname labels the link. */ + title?: string | undefined + /** A short excerpt or summary shown under the link. */ + snippet?: string | undefined + /** Publication/crawl timestamp, a provider-supplied string shown under the link. */ + publishedAt?: string | undefined +} + +/** A `web_search` card: an optional answer over a capped citation list. */ +export interface WebSearchBlockProps { + kind: 'search' + /** The provider-generated answer, rendered as markdown above the sources. */ + answer?: string | undefined + /** The cited sources, in provider order. */ + sources: WebSourceView[] + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */ + maxSources?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A `web_fetch` card: the retrieval summary for one fetched URL. */ +export interface WebFetchBlockProps { + kind: 'fetch' + /** The final URL after allowed redirects; becomes a safe external link when http(s). */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the fetched content. */ + truncated: boolean + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A completed web retrieval card, discriminated by `kind`. */ +export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps + +/** + * The URL to link to, or undefined when the URL must render as plain text. The + * allowlist is MarkdownText's own for untrusted links: only http(s) becomes a + * navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an + * unparseable string never reaches the DOM as an href. + * @param url - the source or fetch URL, from tool result content. + * @returns the href to use, or undefined for plain text. + */ +function safeHref(url: string): string | undefined { + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + +/** + * The link's visible label: the title when the provider gave one, otherwise the + * URL's hostname, falling back to the raw URL when it does not parse. + * @param url - the source URL. + * @param title - the provider title, if any. + * @returns the label text. + */ +function linkLabel(url: string, title: string | undefined): string { + if (title !== undefined && title !== '') return title + try { + return new URL(url).hostname + } catch { + return url + } +} + +/** + * A single URL rendered as a safe external anchor, or as plain text when the + * URL is not an http(s) link. + * @param props.url - the URL to render. + * @param props.label - the visible label. + * @param props.className - class for the anchor or the plain span. + * @returns the anchor or span element. + */ +function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) { + const href = safeHref(url) + if (href === undefined) return {label} + return ( + + {label} + + ) +} + +/** + * One source row in a search card: the safe link plus its snippet and date. + * @param props.source - the source to render. + * @returns the source list item. + */ +function SourceItem({ source }: { source: WebSourceView }) { + return ( +
  • + + {source.snippet !== undefined && source.snippet !== '' && ( +
    {source.snippet}
    + )} + {source.publishedAt !== undefined && source.publishedAt !== '' && ( +
    {source.publishedAt}
    + )} +
  • + ) +} + +/** + * The search card body: the answer over the capped source list. + * @param props - see {@link WebSearchBlockProps}. + * @returns the search card element. + */ +function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) { + const [expanded, setExpanded] = useState(false) + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + const hidden = sources.length - maxSources + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock's output cap, so a long body's head + // and tail slices agree between the two cards. + const headCount = Math.ceil(maxSources / 2) + const tailCount = maxSources - headCount + const head = capped ? sources.slice(0, headCount) : sources + const tail = capped ? sources.slice(sources.length - tailCount) : [] + return ( +
    + {answer !== undefined && answer !== '' && ( +
    + )} +
      + {head.map((source, index) => )} + {hidden > 0 && ( + + )} + {tail.map((source, index) => )} +
    + {truncated &&
    来源列表已截断
    } +
    + ) +} + +/** + * The fetch card body: the linked URL and its HTTP status. + * @param props - see {@link WebFetchBlockProps}. + * @returns the fetch card element. + */ +function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) { + return ( +
    + +
    + HTTP {statusCode} + {truncated && 内容已截断} +
    +
    + ) +} + +/** + * Render a completed web retrieval as a structured card. + * @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body. + * @returns the web card element. + */ +export function WebBlock(props: WebBlockProps) { + return props.kind === 'search' ? : +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..148199142e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' +export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx new file mode 100644 index 0000000000..0c39e8b13d --- /dev/null +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +// WebBlock: both kinds of the web card. The search card's answer, its citation +// list with the title-or-hostname label fallback and optional snippet/date, the +// source-list height cap and its expand control, and the truncated indicator; +// the fetch card's linked URL, status, and truncation. Safe-link attributes on +// both kinds: an http(s) URL becomes an external anchor (target/rel), any other +// URL renders as plain text with no href. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts' +import type { WebSourceView } from '../src/index.ts' + +afterEach(cleanup) + +/** `count` sources with sequential hostnames, so the cap slices read distinctly. */ +function sources(count: number): WebSourceView[] { + return Array.from({ length: count }, (_value, index) => ({ + url: `https://site-${index}.example.com/page`, + title: `Source ${index}`, + })) +} + +describe('WebBlock search card', () => { + it('renders the answer above the citation list', () => { + const view = render() + expect(view.getByText('Answer')).toBeTruthy() + expect(view.getByText('Source 0')).toBeTruthy() + expect(view.getByText('Source 1')).toBeTruthy() + }) + + it('omits the answer block when there is no answer', () => { + const view = render() + expect(view.container.querySelector('[class^="_answer_"]')).toBeNull() + const empty = render() + expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull() + }) + + it('labels a source by its title, and by hostname when the title is absent', () => { + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + // No title / empty title: the hostname labels the link. + expect(view.getByText('plain.example.org')).toBeTruthy() + expect(view.getByText('empty.example.net')).toBeTruthy() + }) + + it('renders a source as a safe external anchor for an http(s) url', () => { + const view = render() + const anchor = view.getByText('Titled') as HTMLAnchorElement + expect(anchor.tagName).toBe('A') + expect(anchor.getAttribute('href')).toBe('https://example.com/a') + expect(anchor.getAttribute('target')).toBe('_blank') + expect(anchor.getAttribute('rel')).toBe('noopener noreferrer') + }) + + it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => { + const view = render() + const unsafe = view.getByText('Dangerous') + expect(unsafe.tagName).toBe('SPAN') + expect(unsafe.getAttribute('href')).toBeNull() + // An unparseable url is not a link and cannot yield a hostname, so its raw + // text is the label. + const raw = view.getByText('not a url') + expect(raw.tagName).toBe('SPAN') + }) + + it('shows a source snippet and publication date when present, and omits them when absent or empty', () => { + const view = render() + expect(view.getByText('excerpt')).toBeTruthy() + expect(view.getByText('2026-07-01')).toBeTruthy() + // The empty-string and absent arms both draw nothing beyond the link. + expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1) + expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1) + }) + + it('shows the truncated indicator only when the list was capped by the tool', () => { + const on = render() + expect(on.getByText('来源列表已截断')).toBeTruthy() + cleanup() + const off = render() + expect(off.queryByText('来源列表已截断')).toBeNull() + }) + + it('renders every source and no expand control under the cap', () => { + const view = render() + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) + expect(view.container.querySelector('[aria-expanded]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const view = render() + // maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden. + expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)) + .toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9']) + const toggle = view.getByRole('button', { name: '展开其余 6 条来源' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toBe('… 其余 6 条来源') + + fireEvent.click(toggle) + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起来源' }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(collapse.textContent).toBe('收起') + + fireEvent.click(collapse) + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0']) + expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy() + }) + + it('caps at the documented default when maxSources is absent', () => { + const view = render() + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES) + expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy() + }) +}) + +describe('WebBlock fetch card', () => { + it('renders the fetched url as a safe external anchor and its HTTP status', () => { + const view = render() + const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement + expect(anchor.tagName).toBe('A') + expect(anchor.getAttribute('href')).toBe('https://example.com/page') + expect(anchor.getAttribute('target')).toBe('_blank') + expect(anchor.getAttribute('rel')).toBe('noopener noreferrer') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('renders a non-http fetch url as plain text with no href', () => { + const view = render() + const label = view.getByText('file:///etc/passwd') + expect(label.tagName).toBe('SPAN') + expect(label.getAttribute('href')).toBeNull() + }) + + it('shows the truncated indicator only when the content was cut', () => { + const on = render() + expect(on.getByText('内容已截断')).toBeTruthy() + cleanup() + const off = render() + expect(off.queryByText('内容已截断')).toBeNull() + }) + + it('carries a non-200 status verbatim', () => { + const view = render() + expect(view.getByText('HTTP 404')).toBeTruthy() + }) +}) From 8c5c4b46c83562611eb4bf3fe9adf60fdc35c81b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:32:55 +0800 Subject: [PATCH 28/85] =?UTF-8?q?fix(web):=20address=20diff=20card=20revie?= =?UTF-8?q?w=20=E2=80=94=20split=20terminator,=20error=20arm,=20wire=20nar?= =?UTF-8?q?rowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiffBlock: an empty side contributes zero lines and a trailing newline is a terminator, so a create ending in a newline draws one added line (not a phantom empty one) and a full deletion draws no phantom + line. - diffCardModel: narrow the wire diffs payload (card is the only validated field) so a malformed diff card falls back to the generic path instead of throwing inside DiffBlock. - FileMutationRow: surface the result text when an errored mutation has no diff card, so a failed edit/write is more than a red dot. - copyText ends its closed union on assertNever. - Docs: drop the "bridge relativizes" claim, record the file-count divergence from the TUI footer, correct the built-boot overclaim, note why the row title outranks the view title, and make fixture turn 67 args self-consistent. - Tests: terminator/empty-side/interior-blank rows, wire-narrowing null arms, the error-text arm and its name/code fallback, stopped state, no-path summary, and the registration/disposal shape. --- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 6 +- .../feature/2026-07-30-web-diff-card.zh.md | 6 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/contract/diff-card-model.ts | 40 ++++++++- .../toolviews/file-mutation-row.module.css | 11 +++ .../client/toolviews/file-mutation-row.tsx | 25 ++++++ .../ui-conversation/tests/diff-card.spec.tsx | 90 ++++++++++++++++++- .../client/ui-primitives/src/DiffBlock.tsx | 36 ++++++-- .../ui-primitives/tests/diff-block.spec.tsx | 20 +++++ 10 files changed, 221 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 18f2d5178b..7ed620736c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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-30-web-diff-card.md -2026-07-30-web-diff-card.md: 5e43d5d29f7f4000efebc166724ec9d921d2b441 -2026-07-30-web-diff-card.zh.md: aac577cfa8dd9e0bf5f17a729d049d207a64d374 +2026-07-30-web-diff-card.md: 8087ce698e65f78c7c6f51211ef00e3b0ab58ed9 +2026-07-30-web-diff-card.zh.md: d85ac1f2e13c7fb3732b327b40122076337ac538 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 5e43d5d29f..8087ce698e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,9 +16,9 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract mirrors the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same across front ends: +The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends, with one deliberate divergence noted below (the file count): -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts distinct paths. +- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths — the divergence from the TUI, whose footer uses `diffs.length` and so reads two hunks in one file as `2 files` where this reads `1 file`. - **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. - **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. @@ -47,7 +47,7 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc `packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. -The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so the built-boot snapshot pins all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). +The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index aac577cfa8..d85ac1f2e1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,9 +16,9 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约镜像 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来一致: +组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态,仅文件计数一处刻意分歧(见下): -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计去重后的路径数。 +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计**去重后的路径数** —— 这是与 TUI 的分歧:TUI 页脚用 `diffs.length`,同文件两个 hunk 在那里读作 `2 files`,此处读作 `1 file`。 - **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 - **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 @@ -47,7 +47,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX `packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 -fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 built-boot snapshot 在两个渲染点钉住全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。 +fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。 ## Related diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4fddf6e9c0..4082d469e8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -238,7 +238,7 @@ function buildAlphaLog(): SessionEvent[] { // the presenter reads to emit the two-hunk sample: the card draws one path // header, the first hunk, a `⋯` gap, then the second (the same-file // second-hunk arm turns 62/63 cannot reach). - toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"multi","new_string":"multi"}', '已编辑') + toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑') // Turn 64: one run_code turn with three logged sub-dispatches — the Code // Mode acceptance surface (parent code row + nested native-identical rows, // including an isError sub-call and a bash sub-call that must hit the same diff --git a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts index f02ccd5930..bc914e4820 100644 --- a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -7,7 +7,7 @@ * call this, so the hunks they show are derived once. * @module */ -import type { DiffBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' /** @@ -35,6 +35,30 @@ export interface DiffCardModel { card: Pick } +/** + * Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event + * view crosses the wire and `toolEventViewSchema` validates only the `card` + * string, so a version mismatch or an anomalous plugin can deliver a `diff` card + * whose `diffs` is absent, not an array, or carries malformed hunks. Returning + * null for any of those routes the block to the generic path instead of letting + * DiffBlock's `for...of`/`split` throw and crash the row or the details panel. + * @param diffs - the view's `diffs` field, unverified. + * @returns the validated hunks, or null when the payload is not usable. + */ +function narrowDiffs(diffs: unknown): DiffHunk[] | null { + if (!Array.isArray(diffs) || diffs.length === 0) return null + const out: DiffHunk[] = [] + for (const hunk of diffs) { + if (typeof hunk !== 'object' || hunk === null) return null + const { path, oldText, newText } = hunk as Record + if (typeof path !== 'string') return null + if (oldText !== null && typeof oldText !== 'string') return null + if (typeof newText !== 'string') return null + out.push({ path, oldText, newText }) + } + return out +} + /** * Derive the diff-card props for a tool call, or null when this call is not a * diff card and belongs on the generic path. @@ -49,6 +73,14 @@ export interface DiffCardModel { * be trusted to be one of the compiled variants — and a settled call whose * result view is generic (how write/edit keep their execution errors on the * generic path). + * + * This derivation consumes only `diffs`; the render intent's `title` field is + * deliberately dropped. The row supplies its own title (`Edit`/`Write · path` + * from the args) and that outranks the view's `title`, matching the TUI diff + * branch, which likewise draws no view title. A tool that names its own diff + * header therefore does not surface that text on the Web row — an accepted + * product choice, recorded here as the one asymmetry with the terminal card, + * whose derivation does consume the view's title. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the diff-card props, or null for the generic path. */ @@ -56,11 +88,13 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { if (!('kind' in block)) { // Running: the call view may carry the intended diff; the result is absent. const call = block.callView?.card === 'diff' ? block.callView : null - return call === null ? null : { card: { diffs: call.diffs } } + const diffs = call === null ? null : narrowDiffs(call.diffs) + return diffs === null ? null : { card: { diffs } } } // Settled: the result view's applied hunks replace the call-time diff. A // window that dropped the call head leaves only the result, which still // renders — the result view carries the whole change. const result = block.resultView?.card === 'diff' ? block.resultView : null - return result === null ? null : { card: { diffs: result.diffs } } + const diffs = result === null ? null : narrowDiffs(result.diffs) + return diffs === null ? null : { card: { diffs } } } diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css index b87103aa3a..3ecf480adf 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -117,3 +117,14 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +/* The result text for an errored mutation, indented to the card's own column + (the diff card's inset) and in the error tone, since it stands in for the diff + card the failure path does not produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 0862eb4fd5..e777c78932 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -39,6 +39,27 @@ function stateStatus(state: ToolRowState): string | null { } } +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the diff card cannot: write/edit return `undefined` from + * `presentResult` on `result.isError`, so an errored mutation has no diff card, + * and the keyed row is not a details-panel target. Without this the failure — + * an `old_string` that did not match, a permission denial — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + /** * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, * with the applied diff resident below it. The summary is a path link (a file @@ -50,6 +71,9 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps const diff = diffCardModel(block) const status = stateStatus(model.state) const filePath = model.filePath + // An errored mutation has no diff card (presentResult returns undefined on + // isError); surface its result text so the failure is more than a red dot. + const failure = diff === null && model.state === 'error' ? errorText(block) : null return (
    @@ -72,6 +96,7 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps {diff !== null && ( )} + {failure !== null &&
    {failure}
    }
    ) } diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 77b762c38b..031216b9f7 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -17,7 +17,7 @@ import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff- import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' -import { FileMutationRow } from '../src/client/toolviews/file-mutation-row.tsx' +import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx' afterEach(cleanup) @@ -86,6 +86,22 @@ describe('diffCardModel', () => { callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, }))).toBeNull() }) + + it('falls back to null for a malformed diff payload off the wire', () => { + // toolEventViewSchema validates only the `card` string, so a version + // mismatch can deliver a diff card with an unusable diffs field. Each shape + // routes to the generic path instead of throwing inside DiffBlock. + const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView) + expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull() + // The running side narrows identically. + expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull() + }) }) describe('chat row diff body', () => { @@ -176,6 +192,78 @@ describe('FileMutationRow diff card', () => { const view = render() expect(view.container.querySelector('[data-diff]')).toBeNull() }) + + it('surfaces the result text when an errored mutation has no diff card', () => { + // write/edit return undefined from presentResult on isError, so the failure + // has no diff — the row shows the model-facing error text instead of a bare + // red dot. + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() + }) + + it('shows no failure text for a successful diff or a running call', () => { + const ok = render() + expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull() + cleanup() + const run = render() + expect(run.container.querySelector('[class*="_failure_"]')).toBeNull() + }) + + it('shows the stopped state when the call was interrupted', () => { + const view = render() + expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + // The visually-hidden status label carries the stopped semantic for AT. + expect(view.getByText('已停止')).toBeTruthy() + }) + + it('renders a plain summary span when the call carries no file path', () => { + // Empty args leave deriveFilePath undefined, so the summary is not a link. + const view = render() + expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull() + expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull() + }) +}) + +describe('fileMutationToolview registration', () => { + it('registers one component under both edit and write, and each disposes', () => { + const registered: { key: string; disposed: boolean }[] = [] + const disposers: (() => void)[] = [] + const ctx = { + slots: { + register: ({ key }: { name: string; key: string }) => { + const entry = { key, disposed: false } + registered.push(entry) + const dispose = () => { entry.disposed = true } + disposers.push(dispose) + return dispose + }, + }, + } + fileMutationToolview.apply(ctx as never) + expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) + // The registrant's inject seam is the load-order contract the row relies on. + expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) + // Disposal removes each contribution (packages/AGENTS.md registry contract). + for (const dispose of disposers) dispose() + expect(registered.every(r => r.disposed)).toBe(true) + }) }) describe('DetailsPanel diff Output section', () => { diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index ab1700b7b5..12c389c72b 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -26,7 +26,7 @@ export const DEFAULT_DIFF_MAX_LINES = 16 * free of the tool contract (the terminal card's decoupling, applied to diffs). */ export interface DiffHunk { - /** The changed file's path (as the tool operated on it; the bridge relativizes it). */ + /** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */ path: string /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */ oldText: string | null @@ -49,6 +49,12 @@ interface DiffRow { text: string } +/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable diff row kind: ${String(value)}`) +} + /** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */ const ROW_CLASS: Record = { path: css.path, @@ -61,8 +67,10 @@ const ROW_CLASS: Record = { * Flatten the hunks into the body's rows plus the footer counts. A path header * opens each new file; a same-file second hunk (a scattered edit) opens with a * `⋯` gap instead of repeating the path. Every old-side line counts toward - * `removed` and every new-side line toward `added`, the same per-side line count - * the TUI footer draws, so the two front ends agree on a change's size. + * `removed` and every new-side line toward `added`. The file count is of + * DISTINCT paths, which is the one deliberate divergence from the TUI diff card: + * the TUI footer uses `diffs.length`, so two hunks in one file read there as + * `2 files`, whereas this counts the one file they belong to. * @param diffs - the hunks to render. * @returns the body rows, the +/- totals, and the distinct-file count. */ @@ -78,12 +86,12 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed else rows.push({ kind: 'gap', text: '⋯' }) prevPath = diff.path if (diff.oldText !== null) { - for (const line of diff.oldText.split('\n')) { + for (const line of contentLines(diff.oldText)) { rows.push({ kind: 'del', text: line }) removed++ } } - for (const line of diff.newText.split('\n')) { + for (const line of contentLines(diff.newText)) { rows.push({ kind: 'add', text: line }) added++ } @@ -91,6 +99,21 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed return { rows, added, removed, files: paths.size } } +/** + * Split a side's text into its content lines. Empty text is zero lines (a full + * deletion's `newText` or a create's absent `oldText` side draws nothing), and a + * single trailing newline is a line terminator rather than an extra empty line — + * the same terminator rule TerminalBlock applies to command output. An interior + * blank line (a genuine `\n\n`) survives. + * @param text - the removed or added side's text. + * @returns the content lines, without the terminating newline. + */ +function contentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + /** * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its * content, exactly what the card shows. The removed and added blocks are the @@ -103,8 +126,9 @@ function copyText(rows: DiffRow[]): string { switch (row.kind) { case 'del': return `- ${row.text}` case 'add': return `+ ${row.text}` + case 'path': return row.text case 'gap': return row.text - default: return row.text + default: return assertNever(row.kind) } }).join('\n') } diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.spec.tsx index d732a13315..bb4a2fdac3 100644 --- a/packages/client/ui-primitives/tests/diff-block.spec.tsx +++ b/packages/client/ui-primitives/tests/diff-block.spec.tsx @@ -76,6 +76,26 @@ describe('DiffBlock structure', () => { const { container } = render() expect(container.firstChild).toBeNull() }) + + it('treats a trailing newline as a terminator, not an extra blank line', () => { + // A create whose newText ends in a newline is one added line, not two, and + // the footer counts one — the phantom `+ ` empty line the naive split drew. + const { container } = render() + expect(changeRows(container)).toEqual(['hello']) + expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('renders a full deletion as removed-only with no phantom added line', () => { + // newText '' is zero added lines: an empty string must contribute nothing. + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0) + expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy() + }) + + it('keeps a genuine interior blank line', () => { + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3) + }) }) describe('DiffBlock footer', () => { From a0a9e9733a7af0500046d24213cb44eb9bbba845 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:04:56 +0800 Subject: [PATCH 29/85] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a8ec59bb6a..bbde233bbc 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: d3cd5cc268b60b58bb4dbb6c3b6c118084c0def8 -README.zh.md: f3a835ed82ecbd266b9f0829acc6182209940cb5 +README.md: 14b754a1a55c5455a068e45077f58411a380d955 +README.zh.md: 554a33779000d773010054766edfa0cb2e9461e0 From 17ee269f115a3fc3fc9c5028d1549e8681b87d66 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:36:17 +0800 Subject: [PATCH 30/85] =?UTF-8?q?fix(web):=20address=20web=20card=20review?= =?UTF-8?q?=20=E2=80=94=20panel=20body,=20ol=20numbering,=20safe=20links,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DetailsPanel: render the flattened result content below the web card, so a web_fetch's fetched body (and a search's answer/source markdown) stays visible on the panel's single-call reading surface — the card is a summary. - WebBlock: the collapsed source tail keeps each source's original citation number via
  • , and the expand control is a marker-less
  • so the
      is valid HTML; an empty-hostname URL (file:/data:) falls back to the raw URL so a label is never blank. - web-row / GenericToolCard: both spread WebBlock uniformly with maxSources (fetch ignores it, like TerminalBlock's maxLines), dropping the duplicated per-kind conditional. - Docs: WebBlock added to the ui-primitives README (both languages) with a Web retrieval section; the ui-conversation README's "inline licensed for this intent alone" claim de-absolutized and a web-card paragraph added; the Agent Note's safe-link description corrected to the http(s) subset of MarkdownText's allowlist (mailto excluded). Fixture source comment aligned with its data. - Tests: ol numbering + marker-less expander, empty-hostname label fallback, the fetched body visible in the panel. --- ...6-07-30-web-result-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-result-card-frontend.md | 4 +- .../2026-07-30-web-result-card-frontend.zh.md | 4 +- .../client/connection/src/client/fixture.ts | 5 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/GenericToolCard.tsx | 5 +- .../src/client/skeleton/DetailsPanel.tsx | 16 +++- .../src/client/toolviews/web-row.tsx | 5 +- .../ui-conversation/tests/web-card.spec.tsx | 6 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/WebBlock.module.css | 4 + .../client/ui-primitives/src/WebBlock.tsx | 77 ++++++++++++------- .../ui-primitives/tests/web-block.spec.tsx | 27 +++++++ 17 files changed, 132 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index e2cb16b5cc..db354e75a3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-30-web-result-card-frontend.md -2026-07-30-web-result-card-frontend.md: cf2dd26a8f6eebe6d8d5d275a462151b7c3274a2 -2026-07-30-web-result-card-frontend.zh.md: 505243f7a31df1a5be0381a9399c5283272d1fe8 +2026-07-30-web-result-card-frontend.md: efead1f404879c526475a1b9f0b31f6c34978f07 +2026-07-30-web-result-card-frontend.zh.md: 2e76bf4c18f5032cd5fd9b15fe31d3c7b036b502 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index cf2dd26a8f..efead1f404 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -14,7 +14,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. -**Links are safe by the same allowlist MarkdownText applies to untrusted assistant-authored links.** A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. +**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. **Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. @@ -32,7 +32,7 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f **Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it. -**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist matches MarkdownText's, so untrusted links behave identically wherever they render. +**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index 505243f7a3..2e76bf4c18 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -14,7 +14,7 @@ Status: implemented 一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 -**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用的同一 allowlist。** 一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 +**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 **几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 @@ -32,7 +32,7 @@ Status: implemented **重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。 -**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 与 MarkdownText 的一致,因此不受信任的链接无论在何处渲染都行为相同。 +**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。 ## Testing diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0196713372..7c81d702b7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -152,8 +152,9 @@ interface WebSourceFixture { * The structured `web_search` result view for fixture turn 66, authored inline * because this client-side fixture cannot import the web tool that projects it. * The sources exercise the citation list's features: a titled source with a - * snippet and a date, a source with no title (its hostname labels the link), and - * a source with a snippet but no date. `truncated` marks the capped indicator. + * snippet and a date, a source with no title (its hostname labels the link) and + * a snippet but no date, and a source with a title and a date but no snippet. + * `truncated` marks the capped indicator. */ const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncated: boolean } = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6ca5e5fa30..447c2b58ea 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 855c42b3377e80b0d8f21a418da0a591782439e1 -README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0 +README.md: bc28e9acb7771a0276ee161ea507916d0d45edeb +README.zh.md: 1fea2ee8aaee3e327c689df400a50ab58a61481a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 855c42b337..bc28e9acb7 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,9 @@ Approvals take over the composer through the chain this package declares: `Appro Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). + +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, or a `card` tag this client version does not know. The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). 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`/`openFile`) 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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 31cf2c7b5a..1fea2ee8aa 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,9 @@ 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 + +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view,或本客户端版本不认识的 `card` 标签,它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5c63896825..56d239b88f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -56,10 +56,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner return (
      {row} - +
      ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index d31717da44..57c5d63460 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -152,8 +152,20 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u } const web = webCardModel(material.block) // Full source-list allowance here (the panel is the single-call reading - // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. - if (web !== null) return + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. The card is a + // summary — a web_fetch card shows only the URL and status — so the details + // panel also renders the flattened result content below it (the fetched body, + // the search answer + source markdown), which the card does not carry. + if (web !== null) { + const settled = 'kind' in material.block ? material.block : null + const body = settled === null ? '' : renderResult(settled) + return ( + <> + + {body !== '' &&
      {body}
      } + + ) + } // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
      运行中…
      diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx index 29a30776f9..b86c523a26 100644 --- a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -66,10 +66,7 @@ export function WebRow({ toolName, block }: ToolRowProps) { {model.summary}
  • {web !== null && ( - + )}
    ) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 9ed25978f1..d6f04eb147 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -216,11 +216,15 @@ describe('DetailsPanel web Output section', () => { expect(view.getByText(/"query"/)).toBeTruthy() }) - it('renders the fetch card', () => { + it('renders the fetch card and keeps the fetched body below it', () => { const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) const card = view.container.querySelector('[data-web="fetch"]') expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') expect(view.getByText('HTTP 200')).toBeTruthy() + // The card is a summary (URL + status only); the panel is the single-call + // reading surface, so the fetched body still renders below the card. + const output = view.getByText('Output').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('fetch body') }) it('a non-web result keeps the flattened pre form', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..2a64292653 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: b5b79f7f0d01afb2d06fb18bf30131cbdda75ca2 +README.zh.md: b10183496479249346da5da808f5ab3b6d2eef67 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..b5b79f7f0d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and WebBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Web retrieval + +`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
  • `, and the expand control is a marker-less `
  • ` so the `
      ` stays valid HTML. A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..b101834964 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock 与 WebBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## Web 检索 + +`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
    1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
    2. `,使 `
        ` 保持为合法 HTML。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css index d595acb3cc..17383563bf 100644 --- a/packages/client/ui-primitives/src/WebBlock.module.css +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -65,6 +65,10 @@ font: var(--dsw-font-xs-13); } +.expandItem { + list-style: none; +} + .expand { display: block; width: 100%; diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index d4757858a3..f0144bd601 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -5,12 +5,13 @@ // none, with the snippet and publication date below it), and a `fetch` shows a // compact retrieval summary (the linked final URL and its HTTP status). Both // mark a capped retrieval. Every link is a same-origin-safe external anchor: -// only http(s) URLs become anchors (target/rel set), the same protocol allowlist -// MarkdownText applies to untrusted assistant-authored links; an unparseable or -// non-http URL renders as plain text. Geometry, radius, and fonts mirror -// CodeBlock/TerminalBlock so a web card reads as one family with them; a long -// source list caps at maxSources with a head/tail collapse using the same -// arithmetic as TerminalBlock's output cap. +// only http(s) URLs become anchors (target/rel set) — the http(s) subset of the +// allowlist MarkdownText applies to untrusted assistant-authored links (it also +// permits mailto, excluded here); an unparseable or non-http URL renders as +// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a +// web card reads as one family with them; a long source list caps at maxSources +// with a head/tail collapse using the same arithmetic as TerminalBlock's output +// cap. import { useCallback, useState } from 'react' import clsx from 'clsx' @@ -64,6 +65,13 @@ export interface WebFetchBlockProps { statusCode: number /** True when the provider or the output cap cut the fetched content. */ truncated: boolean + /** + * Accepted and ignored, so both card kinds take one uniform prop set (a fetch + * card has no source list to cap) — the same way TerminalBlock accepts one + * `maxLines` across its arms. Lets a render site spread `maxSources` onto + * either kind without a per-kind conditional. + */ + maxSources?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined } @@ -72,10 +80,12 @@ export interface WebFetchBlockProps { export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps /** - * The URL to link to, or undefined when the URL must render as plain text. The - * allowlist is MarkdownText's own for untrusted links: only http(s) becomes a - * navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an - * unparseable string never reaches the DOM as an href. + * The URL to link to, or undefined when the URL must render as plain text. Only + * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:` + * URL or an unparseable string never reaches the DOM as an href. This is the + * http(s) subset of the allowlist MarkdownText applies to untrusted links — + * MarkdownText also permits `mailto:`, deliberately excluded here since a + * retrieval URL is never a mail address. * @param url - the source or fetch URL, from tool result content. * @returns the href to use, or undefined for plain text. */ @@ -90,7 +100,9 @@ function safeHref(url: string): string | undefined { /** * The link's visible label: the title when the provider gave one, otherwise the - * URL's hostname, falling back to the raw URL when it does not parse. + * URL's hostname, falling back to the raw URL when it does not parse OR parses + * to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is + * never blank. * @param url - the source URL. * @param title - the provider title, if any. * @returns the label text. @@ -98,7 +110,8 @@ function safeHref(url: string): string | undefined { function linkLabel(url: string, title: string | undefined): string { if (title !== undefined && title !== '') return title try { - return new URL(url).hostname + const { hostname } = new URL(url) + return hostname === '' ? url : hostname } catch { return url } @@ -123,13 +136,17 @@ function SafeLink({ url, label, className }: { url: string; label: string; class } /** - * One source row in a search card: the safe link plus its snippet and date. + * One source row in a search card: the safe link plus its snippet and date. The + * `
      1. ` pins the source's original 1-based position, so a collapsed list + * whose tail is drawn after the head still numbers each source by its real + * citation index rather than by its position in the visible subset. * @param props.source - the source to render. + * @param props.ordinal - the source's 1-based position in the full list. * @returns the source list item. */ -function SourceItem({ source }: { source: WebSourceView }) { +function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) { return ( -
      2. +
      3. {source.snippet !== undefined && source.snippet !== '' && (
        {source.snippet}
        @@ -163,19 +180,27 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
        )}
          - {head.map((source, index) => )} + {head.map((source, index) => )} {hidden > 0 && ( - +
        1. + +
        2. )} - {tail.map((source, index) => )} + {tail.map((source, index) => ( + + ))}
        {truncated &&
        来源列表已截断
        }
  • diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx index 0c39e8b13d..b0778a636a 100644 --- a/packages/client/ui-primitives/tests/web-block.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -48,6 +48,16 @@ describe('WebBlock search card', () => { expect(view.getByText('empty.example.net')).toBeTruthy() }) + it('labels a source by the raw url when it parses to an empty hostname', () => { + // file:/data:/javascript: URLs parse but have no hostname; the label must + // fall back to the raw URL so it is never blank (and the link stays plain + // text since the protocol is not http(s)). + const view = render() + expect(view.getByText('file:///etc/passwd')).toBeTruthy() + }) + it('renders a source as a safe external anchor for an http(s) url', () => { const view = render( { expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) }) + it('numbers a collapsed tail by each source original position, not its visible slot', () => { + // maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read + // as citations 9 and 10 (via
  • ), not renumbered 3 and 4. + const view = render() + const items = [...view.container.querySelectorAll('li[class^="_source_"]')] + expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10']) + }) + + it('keeps the expander out of the ordered-list numbering', () => { + // The expander is a marker-less
  • , so it is valid inside
      and does not + // consume a citation number between the head and tail sources. + const view = render() + const ol = view.container.querySelector('ol')! + // Every direct child is an
    1. (no bare - } - /> + <> + { setOpen(false) }} + side="top" + anchor={ + + } + /> + + + + + )} + > +
      + +

      {t('confirm.description')}

      +
      + +
      + ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..2fa04b6af4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -43,6 +43,8 @@ interface BenchOptions { variant?: 'hero' | 'composer' placeholder?: string translateHint?: (key: string) => string + translateAccess?: (key: string) => string + command?: (line: string) => Promise accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode @@ -100,12 +102,19 @@ function bench(over?: BenchOptions) { useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, - command: () => Promise.resolve(true), + command: over?.command ?? (() => Promise.resolve(true)), // Mirrors the en 'command.hint' locale entries the production apply wires in. translateHint: over?.translateHint ?? ((key: string) => ({ 'placeholder.default': 'Message the agent', 'placeholder.plan': 'describe your task to generate plan', } as Record)[key] ?? key), + translateAccess: over?.translateAccess ?? ((key: string) => ({ + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record)[key] ?? key), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -450,7 +459,35 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) - it('the Access chip renders the projection value and submits /permission on pick', async () => { + it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'read-only', + } + const { view } = bench({ permissions, command }) + const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Read Only') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + fireEvent.click(items[1]!) + // Optimistic pick + disable until admission resolves (command stub resolves true). + const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement + expect(busy.textContent).toBe('Workspace Write') + expect(busy.disabled).toBe(true) + expect(command).toHaveBeenCalledWith('/permission workspace-write') + await act(async () => {}) + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('requires explicit risk acknowledgement before submitting Full access', async () => { + const command = vi.fn(() => Promise.resolve(true)) const permissions = { options: [ { value: 'workspace-write', name: 'workspace-write' }, @@ -458,20 +495,50 @@ describe('placeholder chrome and control seats', () => { ], currentValue: 'workspace-write', } - const { view } = bench({ permissions }) - const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement - // Title-case display is presentation only; the menu ids stay machine names. - expect(trigger.textContent).toBe('Workspace Write') - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.click(items[1]!) - // Optimistic pick + disable until admission resolves (command stub resolves true). - const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement - expect(busy.textContent).toBe('Danger Full Access') - expect(busy.disabled).toBe(true) + const { view } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + + expect(command).not.toHaveBeenCalled() + expect(view.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + + fireEvent.click(view.getByRole('checkbox', { name: 'I understand the risks and want to continue' })) + expect(enable.disabled).toBe(false) + fireEvent.click(enable) + + expect(command).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledWith('/permission danger-full-access') + expect(view.queryByRole('dialog')).toBeNull() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Full access') await act(async () => {}) - expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('cancels a Full access selection without changing permission and resets acknowledgement', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions, command }) + const openConfirmation = () => { + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + } + + openConfirmation() + fireEvent.click(view.getByRole('checkbox')) + fireEvent.click(view.getByRole('button', { name: 'Cancel' })) + expect(command).not.toHaveBeenCalled() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Workspace Write') + + openConfirmation() + expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) it('a registered entry fills its seat and receives the locked owner prop', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..7df065bb8f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -49,6 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..5915703b06 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -135,6 +135,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..0d60462d9b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -150,6 +150,7 @@ function mount( stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} + translateAccess={(key: string) => key} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index ef790c8b6a..f25135d1da 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -16,12 +16,13 @@ import css from './Modal.module.css' * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome) for dialogs whose figma frame owns its own * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { +export function Modal({ open, onClose, title, description, children, footer, className, contentClassName, headless = false }: { open: boolean onClose: () => void title: string @@ -29,6 +30,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + contentClassName?: string headless?: boolean }) { useEffect(() => { @@ -55,7 +57,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla ? children : ( <> -
      +

      {title}

      }> + Create}> ) expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() expect(screen.getByText('Name it.')).toBeDefined() + expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content') fireEvent.keyDown(document, { key: 'a' }) expect(onClose).not.toHaveBeenCalled() fireEvent.keyDown(document, { key: 'Escape' }) From b76a551e10777aeab38ab177141d60d5192c507d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:25:05 +0800 Subject: [PATCH 33/85] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..78e7d2f143 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 186ae70d16e9e1f1ffeadac441140ae2e5dfd2b5 +README.zh.md: 42604fde855b9e0fadabae9e871362b488957be2 From 917e114b6861cddf2ccc19ededca71abe038cc1e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:25:19 +0800 Subject: [PATCH 34/85] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..5c3353695a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 7b39702d8828b80b50d4bc37dbc9350a674b7fdf +README.zh.md: 7f3989e6446581f8264b6fa605dd975be8518ad7 From 224a9d5f0911f91c9dc1219a6cbdc92c8911a2a1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:42:29 +0800 Subject: [PATCH 35/85] fix(client-web): reject unknown web kind, lock fixture to contract, observe web card in boot smoke - webCardModel returns null for an unknown web `kind` (wire from a newer host) instead of drawing it as a malformed fetch, matching the unknown-`card` and terminal-model wire-boundary default. - Fixture WEB_SEARCH_RESULT/WEB_FETCH_RESULT and the source type derive from the contract's ToolResultView via Extract, so a new contract field fails at the type level rather than drifting silently. - built-boot smoke asserts the web_search/web_fetch turns render their keyed WebRow cards, giving the registration and wire projection an assembled check. - DetailsPanel comment no longer claims the card omits content for search. - ui-primitives README inline-Chinese limitation now lists WebBlock's controls. --- apps/web/tests/built-boot.snapshot.ts | 11 +++++++++ .../client/connection/src/client/fixture.ts | 19 ++++----------- .../src/client/contract/web-card-model.ts | 24 +++++++++++++++---- .../src/client/skeleton/DetailsPanel.tsx | 10 ++++---- .../ui-conversation/tests/web-card.spec.tsx | 4 ++++ .../client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- 8 files changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..8bf48fc761 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -108,6 +108,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) + // The web render intent reaches the assembled boot graph: the fixture's + // web_search / web_fetch turns render their keyed WebRow cards, proving the + // registration, wire projection, and card rendering survive the real bundle + // path (not just the per-package src benches). Without this the whole web + // card could silently fall back to the generic row and every new unit test + // would still pass. + await waitFor(() => { + expect(document.querySelector('[data-web="search"]')).not.toBeNull() + expect(document.querySelector('[data-web="fetch"]')).not.toBeNull() + }, { timeout: 10_000 }) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7c81d702b7..cc8332d081 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,27 +136,16 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind' | 'content'> = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', sources: [ { @@ -179,7 +168,7 @@ const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncate } /** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */ -const WEB_FETCH_RESULT: { url: string; statusCode: number; truncated: boolean } = { +const WEB_FETCH_RESULT: Omit, 'card' | 'kind' | 'content'> = { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200, truncated: false, diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts index 28eb3de2d2..f2b15e023a 100644 --- a/packages/client/ui-conversation/src/client/contract/web-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -40,6 +40,9 @@ export const CHAT_WEB_MAX_SOURCES = 8 * cannot be trusted to be one of the compiled variants, and a generic result * view (a web tool's error path returns the generic card, whose text the * generic path preserves). + * - A web card whose `kind` this UI version does not know (a newer host's + * value): the wire cannot be trusted to be `search` or `fetch`, so it takes + * the generic path rather than rendering as a malformed fetch. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the web-card props, or null for the generic path. */ @@ -61,10 +64,21 @@ export function webCardModel(block: ToolCallBlock): WebBlockProps | null { truncated: result.truncated, } } - return { - kind: 'fetch', - url: result.url, - statusCode: result.statusCode, - truncated: result.truncated, + // Discriminate `fetch` explicitly rather than treating it as the else of + // `search`: a `kind` this UI version does not know arrives over the wire from + // a newer host, and reading it as a fetch would draw an empty URL and + // `HTTP undefined`. It takes the generic path, the same wire-boundary default + // an unknown `card` tag takes above. The static union narrows `kind` to + // `'fetch'` here, but the runtime value is off the wire, so the guard and its + // null fallthrough are load-bearing despite the type. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (result.kind === 'fetch') { + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } } + return null } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 57c5d63460..cb4b9aa7b9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -152,10 +152,12 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u } const web = webCardModel(material.block) // Full source-list allowance here (the panel is the single-call reading - // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. The card is a - // summary — a web_fetch card shows only the URL and status — so the details - // panel also renders the flattened result content below it (the fetched body, - // the search answer + source markdown), which the card does not carry. + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the + // panel also renders the flattened result content — the model-visible text + // the card does not carry verbatim (a web_fetch card shows only the URL and + // status, so its fetched body lives only here; a search card's answer and + // sources are structured, so the flattened form repeats them as the raw text + // the model saw). if (web !== null) { const settled = 'kind' in material.block ? material.block : null const body = settled === null ? '' : renderResult(settled) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index d6f04eb147..e92612c810 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -105,6 +105,10 @@ describe('webCardModel', () => { // documented generic-card default takes it, not a crash. const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + // A web card whose kind this UI version does not know (a newer host's + // value) also takes the generic path, not a malformed fetch. + const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2a64292653..d7eae92129 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: b5b79f7f0d01afb2d06fb18bf30131cbdda75ca2 -README.zh.md: b10183496479249346da5da808f5ab3b6d2eef67 +README.md: 099da3ae3d4e2b45507fd18d279650ef0525f36a +README.zh.md: dc7fa5f78ea758cea75e86eefb0c06ebe92e61d2 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b5b79f7f0d..099da3ae3d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,5 +29,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. +- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, `CodeBlock`'s copy control, and `WebBlock`'s source expand/collapse controls and its source-list and fetch truncation notes are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b101834964..dc7fa5f78e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -28,5 +28,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 +- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件、`CodeBlock` 的复制控件,以及 `WebBlock` 的来源展开/收起控件与它的来源列表与 fetch 截断提示,全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 From 7da7d5784dd286457608dd1a854f76f5cbc0e530 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 20:42:48 +0800 Subject: [PATCH 36/85] Open command menu from composer plus button --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 3 +- ...web-input-machine-and-slash-pipeline.zh.md | 3 +- apps/web/tests/lifecycle-chrome.e2e.ts | 33 +++++++++++- .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/command-menu.expected.md | 6 +++ .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 26 +++++++++- .../src/client/contract/slots.ts | 8 +-- .../ui-conversation/src/client/input/hub.ts | 11 ++++ .../src/client/skeleton/InputBar.tsx | 20 +++++--- .../tests/apply-inject.spec.tsx | 2 + .../ui-conversation/tests/input-bar.spec.tsx | 31 ++++++++--- .../tests/input-matrix.spec.tsx | 4 +- .../tests/input-scenarios.spec.tsx | 10 ++++ .../ui-conversation/tests/skeleton.spec.tsx | 2 + packages/client/ui-slash/README.i18n.yaml | 4 +- packages/client/ui-slash/README.md | 4 +- packages/client/ui-slash/README.zh.md | 4 +- .../client/ui-slash/src/client/controller.ts | 44 +++++++++++++++- .../client/ui-slash/tests/service.spec.ts | 51 +++++++++++++++++++ 36 files changed, 256 insertions(+), 50 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 98423a8c7d..ea630a99f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c +2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 92bb91c3e8..2793e9045f 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -63,7 +63,7 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body @@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 5bee6df52f..e3a35c4e55 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -63,7 +63,7 @@ occurrence 表与 chip 三投影: 对"命令"零知识的触发/菜单/pick 管线: - service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格 session 输入体 @@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | ## 后果 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index d4d684b1de..746cd37e36 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -56,6 +57,34 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await scaffold?.close() }) + it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) + const launcher = page.getByRole('button', { name: 'Commands' }) + await launcher.click() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('text: Commands') + expect(snapshot).not.toContain('text: Skills') + expect(snapshot).not.toContain('text: Subagents') + const launchedBox = await menu.boundingBox() + await page.locator('textarea').first().press('Escape') + await expect.poll(() => menu.count()).toBe(0) + const input = page.locator('textarea').first() + await input.fill('/') + await menu.waitFor({ timeout: 10_000 }) + const typedBox = await menu.boundingBox() + expect(launchedBox).not.toBeNull() + expect(typedBox).not.toBeNull() + expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1) + expect(Math.abs( + launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, + )).toBeLessThan(1) + await input.fill('') + await expect.poll(() => menu.count()).toBe(0) + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -152,6 +181,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..31476daefd 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -33,7 +33,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..4425921fdf 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -47,7 +47,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..64c62f85d6 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md new file mode 100644 index 0000000000..47ba98cf05 --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -0,0 +1,6 @@ +- listbox "Trigger suggestions": + - text: Commands + - option "goal set or view the goal for a long-running task" [selected] + - option "permission Switch the permission preset (sandbox mode + approval policy)" + - option "plan Enter or leave plan mode" + - option "model Select the model for this conversation" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..783964ed31 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -26,7 +26,7 @@ - text: workspace - img - textbox "Describe what you want to build" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..81f1ab608b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -22,7 +22,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..f65b090a16 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -19,7 +19,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..0d013f819d 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -12,7 +12,7 @@ - button "编辑": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..11bb665e71 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -22,7 +22,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..b15a665c45 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -33,7 +33,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..db0c2cfd3a 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 2594f18294..7e67544f04 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -26,7 +26,7 @@ - button "取消编辑": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..48c288909c 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -20,7 +20,7 @@ - button "删除排队消息": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..c520dafae9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,7 +36,7 @@ - img - text: 上下文注入 - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..1172d3ca5d 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..ea2de9fd3d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 8c1a75cd6d6bf8409eda32a75b342a8a84c94706 +README.zh.md: 42fa2df9f43f95ad32a593f79ff303c87cb55a5c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..8c1a75cd6d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..42fa2df9f4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,7 +22,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..0262e82987 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -44,6 +44,10 @@ const ABSENT_LEXICON = { getSnapshot: () => EMPTY_LEXICON, subscribe: () => () => {}, } +const ABSENT_MENU_LAUNCHER = { + getSnapshot: (): string | null => null, + subscribe: () => () => {}, +} /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { @@ -196,15 +200,29 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + toggleCommandMenu: undefined, stop: undefined, command: undefined, translateHint, - hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, + hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER }, } } const shell = inputHub.shell(sessionId) + const slash = inputHub.slash(sessionId) return { keyboard: shell, + toggleCommandMenu: slash === undefined + ? undefined + : (selection) => { + shell.dismissPopup() + const snapshot = shell.snapshot + slash.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. @@ -217,7 +235,11 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, translateHint, - hooks: { notices: shell.notices, lexicon: shell.lexicon }, + hooks: { + notices: shell.notices, + lexicon: shell.lexicon, + menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER, + }, } }, }, InputBar) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..883dfd9723 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -5,7 +5,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -265,14 +265,14 @@ export interface ComposerBarOwnerProps { rightItems?: ReactNode /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ footer?: ReactNode - onAdd?: () => void - addLabel?: string } /** Injected share of the composer-bar entry (package-internal faces). */ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ + toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ stop: (() => void) | undefined /** @@ -294,6 +294,8 @@ export interface ComposerBarInjected { notices: ObservableSnapshot /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ lexicon: ObservableSnapshot> + /** Source name opened by the programmatic menu launcher, or null. */ + menuLauncher: ObservableSnapshot } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2641e0dcc4..7b8fa344d6 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -106,6 +106,17 @@ export class InputHub implements InputService { return this.shell(id) } + /** + * Resolve the optional slash controller for composer chrome that launches + * the shared candidate menu without typing a trigger. + * @param id - session id. + * @returns the resident controller, or undefined when ui-slash is absent. + */ + slash(id: SessionId): SlashController | undefined { + const actx = this.sessions().scope(id) + return actx === undefined ? undefined : this.controller(actx) + } + /** * Default sink: optimistic clear + prompt. The session is always a real * host entity (materialized when its workspace was picked), so there is diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 77747e0ae2..97d9a911bf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -33,13 +33,14 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, translateHint, + renderSlot, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, - onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) const lexicon = useLexicon(s => s) + const commandMenuOpen = useMenuLauncher(source => source === 'command') const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const removed = useSession(s => s.removed) ?? false @@ -256,6 +257,11 @@ export function InputBar({ inputRef.current?.focus() } + const onToggleCommandMenu = (): void => { + const el = inputRef.current + if (el !== null) toggleCommandMenu?.(selectionOf(el)) + } + const primaryLabel = running ? 'Stop generating' : 'Send message' const onPrimary = (): void => { if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled @@ -395,11 +401,13 @@ export function InputBar({ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 87ec0dfde7..954335fde2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -179,9 +179,11 @@ describe('conversation slot inject surface', () => { // hooks compartment still present so the render side's hook order holds. const absent = injectFn(undefined) expect(absent.keyboard).toBeUndefined() + expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() expect(absent.hooks.lexicon.getSnapshot().size).toBe(0) + expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull() // A scope whose service tree lost 'conversation' (the feature fiber // unloaded while a retained inject closure re-runs): fails loud too. const stop = injectFn(ROOT).stop! diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..09abd47458 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -47,6 +47,8 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + commandMenuOpen?: boolean + toggleCommandMenu?: (selection: { start: number; end: number }) => void } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -74,6 +76,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() + const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -97,8 +100,10 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(menuLauncher), stop, command: () => Promise.resolve(true), // Mirrors the en 'command.hint' locale entries the production apply wires in. @@ -120,7 +125,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } } describe('Enter semantics', () => { @@ -205,7 +210,7 @@ describe('running and lock semantics (queue cut 1)', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.disabled).toBe(true) expect(textarea.placeholder).toBe('Session unavailable') - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -438,10 +443,10 @@ describe('strips and variants', () => { }) }) -describe('placeholder chrome and control seats', () => { - it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { +describe('command launcher chrome and control seats', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('Add attachment')).toBeTruthy() + expect(view.getByLabelText('Commands')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^Access mode/)).toBeNull() // Both seats dispatched, nothing rendered. @@ -450,6 +455,18 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) + it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => { + const toggleCommandMenu = vi.fn() + const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) + textarea.setSelectionRange(2, 7) + const launcher = view.getByLabelText('Commands') + expect(launcher.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(launcher) + expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) + act(() => { menuLauncher.set('command') }) + expect(launcher.getAttribute('aria-expanded')).toBe('true') + }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { const permissions = { options: [ @@ -489,10 +506,10 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access chip and attach control (running does not)', () => { + it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..4e8fc9efee 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -43,8 +43,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(createSnapshotStore(null)), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), @@ -170,7 +172,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect((textarea).disabled).toBe(true) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..b45397294c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -129,8 +129,18 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: (selection) => { + const snapshot = shell.snapshot + controller.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(controller.launcher), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..d3feba86a6 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -145,8 +145,10 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} + useMenuLauncher={bindSnapshotSelector(createSnapshotStore(null))} stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index 494ccdad20..5f1f5f23c4 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/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-slash/README.md -README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38 -README.zh.md: 03dac56870de5b083124716825001009b4293736 +README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2 +README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index 29f1a71ce2..5d277a83c5 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 03dac56870..195aec6b76 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 3a8e85afc3..0b57f3dd74 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,6 +40,12 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(MENU_CLOSED) + /** + * Name of the source opened through the programmatic launcher, or null for + * trigger-detected/closed menus. Composer chrome subscribes to this store + * for the launcher's expanded state without owning a second menu model. + */ + readonly launcher: SnapshotStore = createSnapshotStore(null) /** * Aggregated hot reference lexicon, grouped by trigger (decision 21): * sources implementing the lexicon hook are polled with the session @@ -81,6 +87,8 @@ export class SlashController { */ track(draft: string, caret: number, guard: TriggerGuard, draftRev: number): void { if (this.disposed) return + const launched = this.launcher.getSnapshot() !== null + this.clearLauncher() const raw = detectTrigger(draft, caret, guard) if (raw === null) { this.hit = null @@ -90,7 +98,7 @@ export class SlashController { } const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } } const prev = this.menu.getSnapshot() - const same = prev.open && prev.hit !== null + const same = !launched && prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit @@ -101,13 +109,40 @@ export class SlashController { this.reduce({ type: 'close' }) return } - if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { + if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name))) } this.reduce({ type: 'hit', hit }) this.fetchCandidates(hit, roster) } + /** + * Toggle a menu containing exactly one registered source. The supplied hit + * is a synthetic selection span rather than a typed trigger token, but + * picks deliberately reuse the ordinary source callback and scoped input + * mutation pipeline. + * @param source - registered source name under `hit.trigger`. + * @param hit - synthetic hit carrying position and pick-time draft CAS. + */ + toggleSource(source: string, hit: TriggerHit): void { + if (this.disposed) return + if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) { + this.dismiss() + return + } + const match = this.deps.roster.sources(hit.trigger).find(item => item.name === source) + if (match === undefined) { + this.dismiss() + return + } + this.stopFetch() + this.hit = hit + this.launcher.set(source) + this.menu.set(seedGroups(this.menu.getSnapshot(), [source])) + this.reduce({ type: 'hit', hit }) + this.fetchCandidates(hit, [match]) + } + /** * Pointer pick from MenuView: route the clicked candidate through onPick * and execute claim/insert outcomes via the scoped input events. @@ -349,9 +384,14 @@ export class SlashController { this.fetch = null } + private clearLauncher(): void { + if (this.launcher.getSnapshot() !== null) this.launcher.set(null) + } + private reduce(ev: MenuEvent): void { const cur = this.menu.getSnapshot() const next = menuReduce(cur, ev) if (next !== cur) this.menu.set(next) + if (!next.open) this.clearLauncher() } } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 099e2bb4f5..6f398442b9 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -351,6 +351,57 @@ describe('track', () => { }) }) +describe('programmatic source launcher', () => { + it('opens only the requested source and reuses its ordinary pick span', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 2, end: 5, draftRev: 7 }, + } + + controller.toggleSource('command', hit) + await tick() + + expect(controller.launcher.getSnapshot()).toBe('command') + expect(controller.menu.getSnapshot()).toMatchObject({ + open: true, + hit, + groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }], + }) + controller.pick('command', 0) + expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span }) + expect(skill.picks).toHaveLength(0) + expect(controller.launcher.getSnapshot()).toBeNull() + }) + + it('toggles closed, and typed tracking returns to the full trigger roster', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 0, end: 0, draftRev: 1 }, + } + + controller.toggleSource('command', hit) + controller.toggleSource('command', hit) + expect(controller.menu.getSnapshot().open).toBe(false) + expect(controller.launcher.getSnapshot()).toBeNull() + + controller.toggleSource('command', hit) + controller.track('/g', 2, { tier: 'plain' }, 2) + await tick() + expect(controller.launcher.getSnapshot()).toBeNull() + expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill']) + }) +}) + describe('scope-birth warm', () => { it('construction warms every source once with the session projection', () => { const cmd = deferredSource('/', 'command') From 6d416b06c59ca7435f47bfb45439f89d3ddda01b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:48:21 -0700 Subject: [PATCH 37/85] fix(client): gate every full access picker --- apps/web/tests/access-confirmation.e2e.ts | 87 +++++++++++++ .../access-confirmation/ui.expected.md | 10 ++ .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- apps/web/tsconfig.json | 3 +- .../ui-command/src/client/PopupSelectView.tsx | 121 ++++++++++-------- .../client/ui-command/src/client/contract.ts | 11 ++ .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/popup.ts | 53 +++++++- .../ui-command/tests/popup-view.spec.tsx | 42 ++++++ .../client/ui-command/tests/popup.spec.ts | 43 +++++++ .../skeleton/PermissionSelect.module.css | 70 ---------- .../src/client/skeleton/PermissionSelect.tsx | 63 ++++----- .../ui-conversation/tests/input-bar.spec.tsx | 36 ++++++ packages/client/ui-permission/package.json | 3 + .../client/ui-permission/src/client/index.ts | 52 +++++++- .../tests/browser-plugin.spec.ts | 20 ++- packages/client/ui-primitives/src/Modal.tsx | 10 +- .../src/RiskConfirmation.module.css | 73 +++++++++++ .../ui-primitives/src/RiskConfirmation.tsx | 80 ++++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../client/ui-primitives/tests/atoms.spec.tsx | 6 +- pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 36 files changed, 624 insertions(+), 195 deletions(-) create mode 100644 apps/web/tests/access-confirmation.e2e.ts create mode 100644 apps/web/tests/snapshots/access-confirmation/ui.expected.md create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.module.css create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.tsx diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts new file mode 100644 index 0000000000..e8a0875c29 --- /dev/null +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -0,0 +1,87 @@ +// Web e2e scenario: every visible permission picker gates Full access behind +// the same locale-aware, in-page risk confirmation. Zero model calls: the +// scenario boots the shipped Web composition and exercises the real +// permission projection, client command path, HTTP RPC, and pushed update. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +describe('web e2e: Full access confirmation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // CI uses Playwright's pinned browser. A developer may point this one + // scenario at an installed Chromium when the matching browser download + // is temporarily unavailable. + const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH + browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) + // Keep the product default Chinese locale: the golden pins the actual + // registered dictionary rather than a test-local translation callback. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('requires acknowledgement before the composer picker can enable Full access', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation')) + const access = page.locator('button[aria-label^="Access mode"]').first() + await access.waitFor({ timeout: 10_000 }) + + // Normalize the starting preset through the real command path. The + // shipped web config may already start at Full access. + if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) { + await access.click() + await page.getByRole('menuitem', { name: 'Workspace Write' }).click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Workspace Write') + } + + await access.click() + await page.getByRole('menuitem', { name: 'Full access' }).click() + const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' }) + await dialog.waitFor({ timeout: 10_000 }) + const enable = dialog.getByRole('button', { name: '启用 Full access' }) + expect(await enable.isDisabled()).toBe(true) + + // The modal is in this page's body (not a native/new window) and escapes + // the sticky composer's stacking context. + expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + expect(await enable.isEnabled()).toBe(true) + await enable.click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Full access') + expect(await dialog.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md new file mode 100644 index 0000000000..1287e6e565 --- /dev/null +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -0,0 +1,10 @@ +- dialog "确认启用 Full access?": + - heading "确认启用 Full access?" [level=2] + - button "Close": + - img + - img + - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 + - checkbox "我已了解风险,并愿意继续" + - text: 我已了解风险,并愿意继续 + - button "取消" + - button "启用 Full access" [disabled] diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..a57dae2a6a 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..313ee2c08e 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -49,7 +49,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..9552c89d80 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..a76f170451 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..194efaa7c4 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..bbe2387634 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -21,7 +21,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..6812119684 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -14,7 +14,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..293ed5bc37 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..7546b7b124 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..583878f2be 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 2594f18294..30cc7f4a2c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -28,7 +28,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..cd8427dff2 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -22,7 +22,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..3d691ea25d 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -38,7 +38,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..979f16e72b 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..9f8c9c371c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,7 +38,8 @@ "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/queue-actions.e2e.ts", - "tests/skill-invocation-policy.e2e.ts" + "tests/skill-invocation-policy.e2e.ts", + "tests/access-confirmation.e2e.ts" ], "references": [ { diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index ec0bbdd2bb..7e7ad91c34 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -12,7 +12,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -56,23 +56,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. useEffect(() => { - if (!state.open) return + if (!state.open || state.confirming !== null) return const onPointerDown = (ev: PointerEvent): void => { if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return popup.dismiss() } document.addEventListener('pointerdown', onPointerDown, true) return () => { document.removeEventListener('pointerdown', onPointerDown, true) } - }, [state.open, popup]) + }, [state.open, state.confirming, popup]) // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open) searchRef.current?.focus() - }, [state.open]) + if (state.open && state.confirming === null) searchRef.current?.focus() + }, [state.open, state.confirming]) if (!state.open) return null const rows = filterOptions(state.options, state.search) + const confirmation = state.confirming?.confirmation const onKeyDown = (ev: React.KeyboardEvent): void => { // ArrowLeft/ArrowRight fall through on purpose: the search input keeps @@ -99,55 +100,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { } return ( -
      - { popup.setSearch(ev.currentTarget.value) }} - /> - {state.error !== null && ( -
      - {state.error} - {state.status === 'failed' && ( - + <> + {state.confirming === null && ( +
      + { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
      + {state.error} + {state.status === 'failed' && ( + + )} +
      + )} + {state.status === 'pending' &&
      Loading options…
      } + {state.submitting &&
      Applying…
      } + {state.status === 'ready' && rows.length === 0 &&
      No options
      } + {state.status === 'ready' && ( +
      + {rows.map((option, index) => ( +
      { void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
      + ))} +
      )}
      )} - {state.status === 'pending' &&
      Loading options…
      } - {state.submitting &&
      Applying…
      } - {state.status === 'ready' && rows.length === 0 &&
      No options
      } - {state.status === 'ready' && ( -
      - {rows.map((option, index) => ( -
      { void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - {option.label} - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
      - ))} -
      + {confirmation !== undefined && ( + { popup.acknowledge(value) }} + onCancel={() => { popup.cancelConfirmation() }} + onConfirm={() => { void popup.confirm() }} + /> )} -
      + ) } diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index 61ab4de2e2..8498b5def8 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -6,12 +6,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Copy for an option that must be acknowledged before onSelect can run. */ +export interface SelectConfirmation { + readonly title: string + readonly description: string + readonly acknowledgeLabel: string + readonly cancelLabel: string + readonly confirmLabel: string +} + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string readonly label: string readonly detail?: string readonly active?: boolean + /** Optional in-page risk gate owned by the shared popup shell. */ + readonly confirmation?: SelectConfirmation } /** diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index f40078212e..95b3824805 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption, } from './contract.ts' declare module 'cordis' { diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts index c2d30f3213..5e20911820 100644 --- a/packages/client/ui-command/src/client/popup.ts +++ b/packages/client/ui-command/src/client/popup.ts @@ -67,12 +67,17 @@ export interface PopupState { readonly active: number /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ readonly submitting: boolean + /** Option waiting for explicit risk acknowledgement; null during normal selection. */ + readonly confirming: SelectOption | null + /** Caller-controlled checkbox state for the pending confirmation. */ + readonly acknowledged: boolean /** Surfaced settlement failure (options load or onSelect); null when none. */ readonly error: string | null } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, + open: false, command: null, status: 'pending', options: [], search: '', active: 0, + submitting: false, confirming: null, acknowledged: false, error: null, } /** @@ -166,7 +171,7 @@ export class PopupSelectController { */ setSearch(search: string): void { const s = this.state.getSnapshot() - if (!s.open || s.submitting || search === s.search) return + if (!s.open || s.submitting || s.confirming !== null || search === s.search) return this.state.set({ ...s, search, active: 0 }) } @@ -177,7 +182,7 @@ export class PopupSelectController { */ move(dir: 1 | -1): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const rows = filterOptions(s.options, s.search) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length @@ -191,7 +196,7 @@ export class PopupSelectController { */ highlight(index: number): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -209,10 +214,46 @@ export class PopupSelectController { async select(index: number): Promise { const binding = this.binding const s = this.state.getSnapshot() - if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const option = filterOptions(s.options, s.search)[index] if (option === undefined) return - this.state.set({ ...s, submitting: true, error: null }) + if (option.confirmation !== undefined) { + this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) + return + } + await this.settle(binding, option) + } + + /** + * Update the explicit checkbox for the currently pending risk gate. + * @param acknowledged - whether the user has acknowledged the displayed risk. + */ + acknowledge(acknowledged: boolean): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return + this.state.set({ ...s, acknowledged }) + } + + /** Cancel only the risk gate and return to the still-open option picker. */ + cancelConfirmation(): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null) return + this.state.set({ ...s, confirming: null, acknowledged: false }) + } + + /** Settle the gated option only after the checkbox is acknowledged. */ + async confirm(): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return + await this.settle(binding, s.confirming) + } + + /** Run the business settlement for an already admitted option. */ + private async settle(binding: OpenBinding, option: SelectOption): Promise { + const s = this.state.getSnapshot() + if (this.binding !== binding || !s.open || s.submitting) return + this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null }) try { await binding.spec.onSelect(option, binding.context) } catch (error) { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 2cd9891478..db2491fef0 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -32,6 +32,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand the risks', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -143,6 +154,37 @@ describe('PopupSelectView', () => { expect(view.container.childElementCount).toBe(0) }) + it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => { + const onSelect = vi.fn() + const { popup, consume } = await mountOpen({ + options: () => Promise.resolve([GATED]), + onSelect, + }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.queryByLabelText('/theme options')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' })) + expect(enable.disabled).toBe(false) + await act(async () => { fireEvent.click(enable) }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A') + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('canceling a gated option returns to the picker with acknowledgement reset', async () => { + await mountOpen({ options: () => Promise.resolve([GATED]) }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByLabelText('/theme options')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.getByRole('checkbox').checked).toBe(false) + }) + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { let release!: () => void const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts index 87a1070a40..68e8084b80 100644 --- a/packages/client/ui-command/tests/popup.spec.ts +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => { }) describe('select', () => { + it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: GATED, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + await popup.confirm() + expect(onSelect).not.toHaveBeenCalled() + popup.acknowledge(true) + await popup.confirm() + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('cancels a confirmation back to the picker without selecting or consuming', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + popup.acknowledge(true) + popup.cancelConfirmation() + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: null, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + expect(deps.consume).not.toHaveBeenCalled() + }) + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { const seen: Array<{ option: SelectOption; context: Ctx }> = [] const deps = makeDeps() diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index bbc51b10e9..50dce3913f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,73 +41,3 @@ flex: 0 0 auto; color: var(--dsw-alias-label-caption); } - -.confirmation { - width: min(440px, 100%); - max-height: calc(100vh - 48px); - overflow: hidden; -} - -.confirmationContent { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; -} - -@supports (height: 100dvh) { - .confirmation { - max-height: calc(100dvh - 48px); - } -} - -.warning { - display: flex; - align-items: flex-start; - gap: 10px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 22px; -} - -.warning p { - margin: 0; -} - -.warningIcon { - flex: none; - margin-top: 2px; - color: var(--dsw-alias-state-error-primary); -} - -.acknowledgement { - display: flex; - align-items: flex-start; - gap: 10px; - margin-top: 20px; - color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 22px; - cursor: pointer; -} - -.acknowledgement input { - flex: none; - width: 16px; - height: 16px; - margin: 3px 0 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; -} - -.acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); - outline-offset: 2px; -} - -.modalAction { - min-width: 72px; -} - -.confirmAction { - min-width: 136px; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 567abaa27e..d2f8cf8ec0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' @@ -9,8 +9,9 @@ const FULL_ACCESS = 'danger-full-access' /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names - * pass through. Twin of the /permission popup's (client ui-permission) — the - * two permission surfaces must show the same text. + * pass through. Full access intentionally overrides the machine-name + * transform so both permission surfaces use the product label `Full access`; + * the warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -34,6 +35,13 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect const [confirmation, setConfirmation] = useState(null) const [acknowledged, setAcknowledged] = useState(false) + useEffect(() => { + if (!locked && value !== undefined) return + setOpen(false) + setAcknowledged(false) + setConfirmation(null) + }, [locked, value]) + if (value === undefined) return null const currentValue = pick ?? value.currentValue @@ -68,7 +76,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } const confirmFullAccess = (): void => { - if (!acknowledged || confirmation === null) return + if (locked || !acknowledged || confirmation === null) return const id = confirmation closeConfirmation() submit(id) @@ -99,42 +107,19 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } /> - - - - - )} - > -
      - -

      {t('confirm.description')}

      -
      - -
      + description={t('confirm.description')} + acknowledgeLabel={t('confirm.acknowledge')} + cancelLabel={t('confirm.cancel')} + confirmLabel={t('confirm.enable')} + acknowledged={acknowledged} + disabled={locked} + onAcknowledgedChange={setAcknowledged} + onCancel={closeConfirmation} + onConfirm={confirmFullAccess} + /> ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2fa04b6af4..48bfd48c53 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -541,6 +541,42 @@ describe('placeholder chrome and control seats', () => { expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) + it('revokes an open Full access confirmation when the task locks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, session } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + act(() => { session.set(snapshotOf({ removed: true })) }) + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + + it('resets an open Full access confirmation when switching tasks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, props } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + view.rerender() + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index cee54f104c..ee2d789500 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -35,6 +36,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", @@ -43,6 +45,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..2914e96f64 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -8,15 +8,21 @@ * projection (the same host-computed select the composer chip renders); a * pick submits the `/permission ` command line, so both surfaces * write through one path and the pushed projection frame is the one - * confirmation. + * confirmation. The Full access row carries the same explicit risk gate as + * the composer chip; the shared popup shell owns the modal mechanics. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'locale'] + +const FULL_ACCESS = 'danger-full-access' +const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { @@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine /** * Display transform twin of the composer chip's (ui-conversation * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. + * (`workspace-write` → `Workspace Write`); non-kebab host-configured names + * pass through. Full access intentionally uses the product label rather than + * a title-cased machine value; its warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -35,14 +42,25 @@ function displayName(name: string): string { } /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ -function optionsOf(value: PermissionSelect): SelectOption[] { +function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), + ...(option.value === FULL_ACCESS + ? { + confirmation: { + title: t('confirm.title'), + description: t('confirm.description'), + acknowledgeLabel: t('confirm.acknowledge'), + cancelLabel: t('confirm.cancel'), + confirmLabel: t('confirm.enable'), + }, + } + : {}), })) } @@ -54,6 +72,26 @@ function optionsOf(value: PermissionSelect): SelectOption[] { export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', + }), + ctx.locale.register(ACCESS_NS, 'en', { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-permission: Full access confirmation dictionaries') + const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session ctx.effect(() => command.decorate({ @@ -67,7 +105,7 @@ export function apply(ctx: ClientContext): void { options: (session) => { const value = selectOf(sessionFor(session)) if (value === undefined) throw new Error('permission presets are not available on this host') - return Promise.resolve(optionsOf(value)) + return Promise.resolve(optionsOf(value, t)) }, onSelect: async (option, session) => { const live = sessionFor(session) diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..f8fde6d10d 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -54,6 +54,17 @@ async function bench() { ctx.provide('sessions', { binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), }) + const en = { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record + ctx.provide('locale', { + register: () => () => {}, + bind: () => (key: string) => en[key] ?? key, + }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { @@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => { expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // Kebab-case names title-case; non-kebab host-configured names pass through. - expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ + title: 'Enable Full access?', + description: 'Full access can perform sensitive operations.', + acknowledgeLabel: 'I understand the risks and want to continue', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }) b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) const passthrough = await c.ui.options(proj, new AbortController().signal) expect(passthrough[0]?.label).toBe('Ask Every Time') diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index f25135d1da..d4199d9581 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,9 +1,11 @@ // Modal: controlled full-viewport dialog (create-workspace and similar). -// Fixed overlay in the React tree (no react-dom portal) so ui-primitives -// stays free of a react-dom dependency; mask tokens match figma 451:18655. +// The overlay portals to this document's body so ancestor stacking contexts +// cannot leave sticky page controls above the mask. This is still an in-page +// WebUI dialog; it never creates or targets another browser/native window. import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' @@ -44,7 +46,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla if (!open) return null - return ( + return createPortal((
      ) } - case 'context': + case 'context': { + // Pure-text injections show their text; anything with non-text blocks + // (or nothing at all) keeps the full JSON payload so no material is lost. + // Title-only collapsed row (no summary), label-less expanded card: the + // injection is ambient context, not a call's input. + const { text, rest } = contentText(node.content) + const body = rest.length === 0 && text !== '' + ? text + : JSON.stringify({ content: node.content, source: node.source }, null, 2) return (
      - + } + title="上下文注入" + summary="" + body={body} + plainBody + state="ok" + />
      ) + } default: return (
      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 08031f570c..3332882518 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -41,7 +41,8 @@ 90%, 100% { left: 100%; } } -/* Expand-on-row (Think / code): pointer only — no row fill hover. */ +/* Every expandable row is the expand control: pointer only — the icon→chevron + hover preview is the affordance, no row fill. */ .row[data-expandable] { cursor: pointer; } @@ -55,9 +56,6 @@ align-items: center; justify-content: center; margin-right: 6px; - padding: 0; - border: none; - background: none; color: var(--dsw-alias-label-tertiary); } @@ -76,8 +74,8 @@ background: var(--dsw-alias-state-business-primary); } -button.leading { - cursor: pointer; +.chevron { + color: var(--dsw-alias-label-secondary); } /* Hover preview on expandable rows: the idle tool icon crossfades (100ms) @@ -155,8 +153,65 @@ button.leading { text-decoration: underline; } -/* Expanded body: pad-left 22 indented gray text, no border, no fill. */ -.body { +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +/* Hover-revealed jump to the trajectory record: a small pill in real flow + under the expanded body's bottom-left corner (it reserves its line, so + revealing never shifts layout); revealed by hovering anywhere on the tool + call — title row included — or by keyboard focus. */ +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + /* Base background, not bg-overlay: the overlay token is a raised dark + surface and reads too heavy for a quiet in-flow affordance. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +/* Solid hover fill (a translucent token would let content bleed through). */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card + and the terminal card scroll INSIDE their own surface instead, so the + scrollbar sits within the rounded card. */ +.bodyScroll { + max-height: 260px; + overflow-y: auto; +} + +/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card + (the reasoning is not an input payload), pre-wrapped at the row's indent. + Uncapped: reasoning reads as message prose, so it flows with the page + instead of scrolling in a box. */ +.thinkBody { padding: 4px 0 4px 22px; font-size: 14px; line-height: 24px; @@ -165,6 +220,89 @@ button.leading { color: var(--dsw-alias-label-tertiary); } +/* Expanded input/output card (figma 1249:35657): the code-block surface and + radius from the TerminalBlock/CodeBlock family. The card itself is a plain + column — the padding and the IN/OUT gutter-label grid live on each section + so the divider spans the full card width and each section scrolls alone. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +/* One card section (IN or OUT): the gutter-label grid, capped and scrolling + independently so a long input never buries a short output (and vice versa). */ +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so + it floats off the rounded card edge instead of hugging it (the terminal + card's own output scroller carries the same treatment in TerminalBlock). */ +.ioSection::-webkit-scrollbar-thumb, +.ioCardPlain::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +/* Track end-margins keep the thumb's travel out of the rounded corners. */ +.ioSection::-webkit-scrollbar-track, +.ioCardPlain::-webkit-scrollbar-track { + margin: 6px 0; +} + +/* Label-less variant of the card (plainBody): the text is not an IN/OUT pair, + so it renders as one plain padded block scrolling as a whole. */ +.ioCardPlain { + display: block; + padding: 12px 16px; + max-height: 260px; + overflow-y: auto; +} + +/* Caption (not tertiary): one step dimmer than the payload text so the + gutter labels read as labels, not as part of the content. Sticky against + the section's own scroll so the label stays readable while its payload + scrolls underneath (top 0 = the section's padding edge inside the + scrollport; start-aligned because sticky needs a block-start anchor). */ +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +/* l2 hairline between the IN and OUT sections, spanning the full card width + (it sits between the padded sections, not inside their grid). */ +.ioDivider { + flex: none; + height: 1px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +/* A failed call's OUT text shares the collapsed summary's error color. */ +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + /* The two block-shaped expanded bodies: the code variant's run_code program through CodeBlock (shiki-highlighted TypeScript) and a terminal card's command output through TerminalBlock. Both are drawn by the shared @@ -173,15 +311,21 @@ button.leading { flow's row rhythm. */ .codeBody, .terminalBody { - margin: 4px 0 4px 22px; + margin: 4px 0 4px 4px; } -/* Indented to the body's own column so the description reads as the card's - heading rather than as another summary row, and sits tight against the card - below it. Its own rule: grouping it with a body would put description - typography on a `CodeBlock` wrapper and change that body's spacing. */ -.terminalDescription { - margin: 4px 0 0 22px; - color: var(--dsw-alias-label-secondary); - font: var(--dsw-font-xs-13); +/* In-row code renders at the smaller code size (12/18) via each primitive's + rebindable content-font seam; standalone markdown code blocks keep 13/22. */ +.codeBody { + --dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small); +} + +/* The terminal card scrolls its OUTPUT inside its own surface (same l1 + hairline as the IN/OUT card): the banner stays pinned and the scrollbar + never rides over it. 224px = the 260px card cap minus the ~36px banner. */ +.terminalBody { + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + border: 1px solid var(--dsw-alias-border-l1); } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index fe0df556ce..4182f72a52 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -1,17 +1,25 @@ // ToolRow: the single-line tool summary row (figma component set 122:9479) — // 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title + // separator dot + FILL-truncated summary. The collapsed row is always one -// line; the expanded body is indented gray text, the run_code program through -// CodeBlock, or — for a call whose render intent is a terminal card — the -// command's own output through TerminalBlock, capped at -// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is -// component-local view state. File-tool summaries are path links that open -// through the host; the row itself is not a details-panel control. +// line; every row with body, output, or terminal material is a whole-row +// expand toggle (click / Enter / Space, icon→chevron hover preview); the +// summary stays inline while open, except Think, whose body opens with the +// same first line and would repeat it. +// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for +// text input/output, the run_code program through CodeBlock, or a terminal +// card's command output through TerminalBlock — lives in a max-height scroll +// container so a long payload scrolls internally instead of taking over the +// message flow; Think's prose is the exception and flows uncapped like +// message text. Expand state is component-local view state. File-tool summaries are path links that open +// through the host (stopPropagation keeps the two gestures independent); an +// error row's collapsed summary is the failure's first line in the error +// color. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import clsx from 'clsx' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' +import type { TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' import css from './ToolRow.module.css' @@ -23,18 +31,25 @@ export interface ToolRowProps { icon: ReactNode title: string summary: string - /** Expanded-body text; null = no text body (`terminal` is the other body source). */ + /** Expanded-body input text; null = no input section. */ body: string | null + /** Flattened result text for the expanded Output section; null/absent = no output section. */ + output?: string | null | undefined + /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ + errorSummary?: string | null | undefined /** * Terminal-card material for a call whose render intent is a terminal card - * (derived by `terminalCardModel`); it replaces the text body when present. - * Null or absent leaves the text body, and a row with neither is not - * expandable (its leading slot never toggles). + * (derived by `terminalCardModel`); it replaces the text sections when + * present. A row with no body, no output, and no terminal material is not + * expandable. */ terminal?: TerminalCardModel | null | undefined + /** + * Render the expanded body in the card without the IN gutter label — for + * material that is not a call's input payload (context injection). + */ + plainBody?: boolean | undefined state: ToolRowState - /** Makes the row itself the expand control instead of only its leading icon. */ - expandOnRowClick?: boolean | undefined /** * Filesystem path from tool args; when set with onOpenFile, the summary * renders as a hover-underline link that opens the host default app. @@ -42,6 +57,21 @@ export interface ToolRowProps { filePath?: string | undefined /** Open the path with the host OS default application (already cwd-resolved). */ onOpenFile?: ((path: string) => void) | undefined + /** + * Jump to this call in the trajectory view: a hover-revealed Inspect pill + * over the expanded body. Absent = no affordance (rows without a call + * identity, like Think and context injection). + */ + inspect?: (() => void) | undefined +} + +/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */ +function IconInspect() { + return ( + + + + ) } /** Leading-slot state substitution: the tool icon yields to the terminal state @@ -62,36 +92,31 @@ export function ToolRow({ title, summary, body, + output, + errorSummary, terminal, + plainBody, state, - expandOnRowClick = false, filePath, onOpenFile, + inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) const terminalBody = terminal ?? null - // A row that names a single file keeps one interaction (open that path); - // args expand is off whether or not the open callback is wired yet. Terminal - // material still expands: only the file variants carry a path, so a terminal - // card and a file link never land on the same row. - const singleFile = filePath !== undefined - const fileLink = singleFile && onOpenFile !== undefined - const expandable = (body !== null && !singleFile) || terminalBody !== null - // The text arms take the empty string for a null body: a row expandable - // only through its terminal material renders the terminal body instead, so - // this substitution never shows. - const text = body ?? '' + const outputText = output ?? null + const expandable = body !== null || outputText !== null || terminalBody !== null const open = expanded && expandable - const rowExpands = expandable && expandOnRowClick + // An error row's collapsed summary IS the failure: the first error line in + // the error color outranks both the args summary and a terminal description. + const failureLine = state === 'error' ? errorSummary ?? null : null + const summaryText = failureLine ?? summary + // The failure line is error prose, not the path: no open-file affordance. + const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null const toggleExpand = () => { setExpanded(v => !v) } - const toggleFromLeading = (event: MouseEvent) => { - event.stopPropagation() - toggleExpand() - } const toggleFromKeyboard = (event: KeyboardEvent) => { - if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() toggleExpand() } @@ -99,46 +124,47 @@ export function ToolRow({ event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } - // Expandable rows preview the toggle on hover: the tool icon yields to a - // down chevron (CSS swap on .row:hover); state dots still take precedence. + // Think reasoning is prose, not an input payload: expanded, it renders as + // plain indented text (no IN/OUT card) and the inline summary — the body's + // own first line — yields to avoid repeating itself. + const isThink = variant === 'think' + // The code variant's program renders through CodeBlock (shiki), so only its + // output joins the IN/OUT card; every other variant's input does too. + const cardBody = variant === 'code' ? null : body + // Expandable rows preview the toggle on hover: the idle leading — the tool + // icon OR the state dot — yields to a down chevron (CSS swap on .row:hover). + // The state substitution happens inside the idle slot so an error row keeps + // the hover preview instead of losing it with the icon. + const idleLeading = leadingFor(state, icon) const collapsedIcon = expandable ? ( <> - {icon} - + {idleLeading} + ) - : icon + : idleLeading const leading = open - ? - : leadingFor(state, collapsedIcon) + ? + : collapsedIcon return (
      - {expandable && !rowExpands ? ( - - ) : ( - - {leading} - - )} + + {leading} + {title} - {!open && ( + {/* An empty summary drops the separator with it (a row that is only + its title, like the context-injection row, shows no trailing dot). */} + {!(open && isThink) && summaryText !== '' && ( <> {fileLink ? ( @@ -147,25 +173,71 @@ export function ToolRow({ className={css.fileLink} onClick={openFile} > - {summary} + {summaryText} ) : ( - {summary} + + {summaryText} + )} )}
      - {/* The terminal presenter's description belongs ABOVE the card per the - render-intent contract, so an expanded terminal row keeps showing it - even though the collapsed summary is hidden while open. */} - {open && terminalBody?.description !== undefined && ( -
      {terminalBody.description}
      + {open && ( + /* The wrapper (sibling of .row, so clicks inside never toggle the + row) carries the expanded body and the Inspect pill below it. */ +
      + {terminalBody !== null + ? + : isThink + ?
      {body}
      + : ( + <> + {variant === 'code' && body !== null && ( +
      + +
      + )} + {plainBody === true && cardBody !== null && ( +
      + {cardBody} +
      + )} + {plainBody !== true && (cardBody !== null || outputText !== null) && ( +
      + {cardBody !== null && ( +
      + IN + {cardBody} +
      + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
      + OUT + + {outputText} + +
      + )} +
      + )} + + )} + {inspect !== undefined && ( + + )} +
      )} - {open && (terminalBody !== null - ? - : variant === 'code' - ? - :
      {text}
      )}
      ) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..8e20f4054f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -147,13 +147,17 @@ export interface InputZone { } /** - * View-slot owner share: deliberately empty — ConversationRoot supplies - * nothing at its renderSlot site (sessionId and the snapshot hook arrive as + * View-slot owner share: the cross-view inspect handoff (otherwise views need + * nothing from the render site — sessionId and the snapshot hook arrive as * framework-standard props; tool rows go through each view's own declared - * toolview hole). Kept as the named owner seat so a future cross-view - * payload has a home. + * toolview hole). */ -export interface ConvViewOwnerProps {} +export interface ConvViewOwnerProps { + /** One-shot inspect request from another view (chat's Inspect button); null when idle. */ + inspect?: { callId: CallId } | null + /** Acknowledge the inspect request once applied (clears the store field). */ + onInspectDone?: () => void +} /** * Owner share of a per-view toolview slot: the call material the rendering @@ -176,6 +180,11 @@ export interface ToolRowOwnerProps { * The chat view resolves relative paths against the session cwd. */ openFile: (path: string) => void + /** + * Jump to this call's record in the trajectory view (the expanded row's + * hover Inspect affordance). Undefined when no trajectory jump is wired. + */ + inspect?: (() => void) | undefined } /** @@ -419,6 +428,19 @@ export interface ChatViewInjected { */ openFile: (path: string) => void loadOlder: () => void + /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */ + inspectCall: (callId: CallId) => void + /** + * Per-session scroll memory surviving view switches (in-memory, never + * persisted): the view saves on every scroll and restores on remount; a + * fresh page load starts empty and keeps the open-jump-to-bottom default. + */ + chatScroll: { + /** Record the scroll offset; null clears it (pinned to bottom). */ + save: (top: number | null) => void + /** Last recorded offset, or null when pinned or never recorded. */ + read: () => number | null + } } /** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */ diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index e25d68cbbb..8d7750c3ba 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -11,17 +11,6 @@ import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts' -/** - * Output lines the chat row's expanded terminal body shows before collapsing - * the middle — half the primitive's own default, which the details panel - * keeps. A chat row is a summary surface inside the message flow: the flow - * must stay scannable across many calls, while the details panel is the - * single-call reading surface. A design constant of this UI's row geometry, - * not a deployment choice, so it is fixed here rather than a plugin Config - * field. - */ -export const CHAT_TERMINAL_MAX_LINES = 8 - /** * The {@link TerminalBlock} props this derivation owns. Picked off the * primitive's props so the two stay in step; `home` is absent because the web @@ -44,6 +33,20 @@ export interface TerminalCardModel { description: string | undefined } +/** + * True when a settled terminal card reports a failing exit — a non-zero code + * or a terminating signal. The bash tool settles a failing command as a + * completed call (`isError` stays false: the exit status is result data), so + * this is the collapsed row's only failure signal; without it the red exit + * pill would be visible only after expanding the card. + * @param model - a derived terminal card. + * @returns whether the card's exit status is a failure. + */ +export function terminalFailed(model: TerminalCardModel): boolean { + const { exitCode, signal, running } = model.card + return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined) +} + /** * Resolve a terminal view's working directory the way the render-intent * contract assigns to the UI bridge: an absolute path is used as-is, a relative diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index b53ef95c01..d7735fdab2 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -1,14 +1,15 @@ /** * Pure row-model derivation for tool summary rows: variant classification, - * one-line summary and expanded-body text from the frozen call slice. This - * derivation reads the call ARGUMENTS only; a call whose render intent is a - * terminal card gets its expanded body from the views instead, through + * one-line summary, expanded-body text, and flattened result output from the + * frozen call slice. Input material comes from the call ARGUMENTS; output and + * error material from the settled result node. A call whose render intent is + * a terminal card gets its expanded body from the views instead, through * `terminalCardModel` in terminal-card-model.ts. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,11 +71,34 @@ export interface ToolRowModel { * relative values against the session cwd before opening. */ filePath: string | undefined - /** Expanded-body text (pretty args); null = row not expandable. */ + /** Expanded-body input text (pretty args); null = no input section. */ body: string | null + /** Flattened result text ({@link resultText}); null while running or when the result carries no text. */ + output: string | null + /** First line of the result text on an error row; null for every other state. */ + errorSummary: string | null state: ToolRowState } +/** + * Flatten a settled result's content blocks to display text: text blocks + * verbatim, other block shapes as pretty JSON. Empty content on a failed call + * falls back to the structured error's `name: code` line. + * @param node - the settled result node. + * @returns the flattened result text (may be empty). + */ +export function resultText(node: ToolResultNode): string { + const parts: string[] = [] + for (const block of node.content) { + if (block.type === 'text') parts.push(block.text) + else parts.push(JSON.stringify(block, null, 2)) + } + if (parts.length === 0 && node.error !== undefined) { + parts.push(`${node.error.name}: ${node.error.code}`) + } + return parts.join('\n') +} + function parseArgs(argsRaw: string): unknown { try { return JSON.parse(argsRaw) @@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const summary = variant === 'others' && toolName !== '' && toolTitle === undefined ? `${toolName} · ${base}` : base + // The empty string is "no text" for both derived result fields: a settled + // call with blank content has nothing to expand, and a blank first line + // would erase the collapsed error row's summary slot. + const output = done ? (resultText(block) || null) : null + const errorSummary = state === 'error' && output !== null ? firstLine(output) : null return { variant, title: toolTitle ?? VARIANT_TITLES[variant], summary, filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), + output, + errorSummary, state, } } diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 9ef9515f19..a8da4121b9 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -23,4 +23,10 @@ export interface ChatStoreState { draft: string /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */ view: string | null + /** + * One-shot inspect handoff: chat writes the call to reveal, the trajectory + * view consumes it and acknowledges by clearing. Read with `?? null` — + * persisted snapshots from before this field rehydrate without it. + */ + inspect: { callId: CallId } | null } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 33b24f5245..d335cd9218 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -35,6 +35,8 @@ export function ConversationSession({ const blank = useSession(s => s.blank) const inputState = useInput(s => s) const storedDraft = useStore(s => s.draft) + // `?? null`: persisted snapshots from before the inspect field rehydrate without it. + const inspect = useStore(s => s.inspect ?? null) useEffect(() => { if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) @@ -52,7 +54,10 @@ export function ConversationSession({ const view: ReactNode = hideChrome ? null : (
      - {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} + {active !== undefined && renderSlot('conversation.view', { + inspect, + onInspectDone: () => { actions.setInspect(null) }, + }, { only: active.id })}
      ) diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..fb0b69624e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -12,7 +12,7 @@ import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' -import type { ToolCallBlock } from '../contract/tool-call-model.ts' +import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ @@ -153,20 +153,7 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u const result = material.block return (
      -      {renderResult(result)}
      +      {resultText(result)}
           
      ) } - -/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */ -function renderResult(node: ToolResultNode): string { - const parts: string[] = [] - for (const block of node.content) { - if (block.type === 'text') parts.push(block.text) - else parts.push(JSON.stringify(block, null, 2)) - } - if (parts.length === 0 && node.error !== undefined) { - parts.push(`${node.error.name}: ${node.error.code}`) - } - return parts.join('\n') -} diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 4c27a87ed8..8a1cd6f068 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -3,7 +3,7 @@ * The plugin creates its handle at apply time so identity follows the fiber. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatStoreState, SelectionTarget } from './contract/views.ts' +import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts' /** Declared action shape used to give the exported factory a stable return type. */ type ChatActions = { @@ -12,6 +12,7 @@ type ChatActions = { clearDraft: (draft: ChatStoreState) => void restoreDraft: (draft: ChatStoreState, text: string) => void setView: (draft: ChatStoreState, view: string) => void + setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void } /** @@ -20,7 +21,7 @@ type ChatActions = { */ export function createChatStore(): EngineStoreHandle { return defineStore({ - init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), + init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }), persist: 'dsh.conversation.chat', actions: { select: (d, target: SelectionTarget | null) => { d.selection = target }, @@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle { if (d.draft === '') d.draft = text }, setView: (d, view: string) => { d.view = view }, + setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, }, }) } 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 index 97d24088cd..946151cee2 100644 --- a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx @@ -1,7 +1,7 @@ // 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 +// (chrome, running sweep, whole-row expand) 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. @@ -37,8 +37,9 @@ function answeredSummary(text: string): string | null { return `${answered}/${answers.length} answered` } -/** One-line question-interaction row (leading toggle expands the raw args). */ -export function AskQuestionRow({ toolName, block }: ToolRowProps) { +/** One-line question-interaction row (the whole row toggles the call's + * Input/Output sections, ToolRow's unified expand). */ +export function AskQuestionRow({ toolName, block, inspect }: 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 @@ -68,7 +69,9 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) { title="Ask question" summary={summary} body={model.body} + output={model.output} state={state} + inspect={inspect} /> ) } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index e9ee5286dc..fa607a0880 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,5 +1,5 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description), - plus the terminal card the row stacks under its summary line. */ + plus the expand-gated terminal card under the summary line. */ /* Summary line over the terminal card; the summary row keeps its own 24px height, so the card is a column around it rather than a change to it. */ @@ -8,10 +8,23 @@ flex-direction: column; } -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ +/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1 + hairline, and the max-height scroll on the card's own OUTPUT (banner stays + pinned; 224px = the 260px card cap minus the ~36px banner); the margin + replaces the primitive's standalone vertical margin with the flow's. */ .terminal { - margin: 4px 0 4px 22px; + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); +} + +/* ToolRow's unified expand interaction, replicated per the registrant + posture: pointer on the expandable row (the icon→chevron hover preview is + the affordance, no row fill). */ +.root[data-expandable] { + cursor: pointer; } .root { @@ -47,6 +60,7 @@ } .leading { + position: relative; /* .chevronHover overlay anchor */ flex: none; width: 16px; height: 16px; @@ -57,6 +71,34 @@ color: var(--dsw-alias-label-tertiary); } +.chevron { + color: var(--dsw-alias-label-secondary); +} + +/* Hover preview on the expandable row: the idle icon crossfades (100ms) into + a down chevron before the row is opened — same overlay as ToolRow. */ +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .iconIdle { + opacity: 0; +} + +.root:hover .chevronHover { + opacity: 1; +} + .scopeBadge { flex: none; margin-right: 8px; @@ -95,6 +137,52 @@ color: var(--dsw-alias-label-tertiary); } +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +/* Hover-revealed Inspect pill under the expanded terminal's bottom-left — + ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant + posture: real flow (it reserves its line), revealed by hovering anywhere on + the tool call — title row included — or by keyboard focus. */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + /* Base background, not bg-overlay: the overlay token reads too heavy. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.card:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +/* Solid hover fill: the pill floats over terminal output, so a translucent + hover token would let the text underneath bleed through. */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + + .visuallyHidden { position: absolute; width: 1px; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index c9385cab04..1f979e899c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -4,19 +4,23 @@ // Child sessions keep a scoped badge so session-dimension differentiation stays // observable inside the component (no parallel registry). // -// A bash call declares the terminal render intent, so this row also renders -// the command's own output through TerminalBlock. This row has no expand -// control and is not a details-panel target either (tool rows stopped being -// one), so its terminal body is resident rather than expand-gated as in -// ToolRow, and the card's own copy and expand controls are the row's only -// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat -// flow's tighter cap over the block's own default of 16 — and the block's -// internal expander keeps a long output from taking over the message flow. +// A bash call declares the terminal render intent, so this row renders the +// command's own output through TerminalBlock — expand-gated exactly like +// ToolRow's unified interaction: collapsed by default, the whole summary row +// is the toggle (click / Enter / Space, icon→chevron hover preview; the +// summary stays inline while open), +// and the expanded card max-height-scrolls inside its own surface with the +// full output (maxLines Infinity — no middle collapse). An error row's +// collapsed summary is the failure's first line in the error color. +import { useState, type KeyboardEvent } from 'react' import type { Context } from 'cordis' -import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import clsx from 'clsx' +import { + IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts' +import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' @@ -40,38 +44,84 @@ function stateStatus(state: ToolRowState): string | null { } /** - * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the - * command's terminal card resident below it. The summary row is not a - * details-panel control (tool rows stopped being one), so the card's copy and - * expand controls are the row's only interactions. + * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the + * whole row toggling the command's terminal card (ToolRow's unified + * expand interaction, replicated locally per the registrant posture). */ -export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) { +export function BashRow({ toolName, block, sessionId, useSessions, inspect }: ToolRowProps) { const model = toolRowModel(toolName, block) // Session workspace root: the terminal view's cwd resolves against it (an // omitted workdir IS the workspace), which the pure presenter cannot do. const cwd = useSessions(list => list.byId[sessionId]?.cwd) const terminal = terminalCardModel(block, cwd) + // A failing exit status is the terminal card's own error signal (the call + // itself settles isError:false), surfaced as the row's red state dot. + const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) + ? 'error' + : model.state const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) - const status = stateStatus(model.state) + const status = stateStatus(state) + const [expanded, setExpanded] = useState(false) + const expandable = terminal !== null + const open = expanded && expandable + const failureLine = model.state === 'error' ? model.errorSummary : null + const toggleExpand = () => { + setExpanded(v => !v) + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? + : expandable + ? ( + <> + {leadingFor(state)} + + + ) + : leadingFor(state) return (
      - {leadingFor(model.state)} + {leading} {status !== null && {status}} {isChild && scoped} {model.title} {/* The terminal presenter's description is the contractual - above-card summary; it outranks the args-derived one. */} - {terminal?.description ?? model.summary} + above-card summary; a failure's first line outranks both. */} + + {failureLine ?? terminal?.description ?? model.summary} +
      - {terminal !== null && ( - + {terminal !== null && open && ( + /* Same hover-Inspect posture as ToolRow's expanded body, replicated + locally per the registrant posture. */ +
      + + {inspect !== undefined && ( + + )} +
      )}
      ) 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 8a1f20e3e5..22b51b1890 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,10 +1,10 @@ // 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 -// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a +// composes ToolRow (chrome, running sweep, whole-row expand) 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. +// row stays one line until expanded. import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from 'cordis' @@ -40,10 +40,11 @@ function summarize(argsRaw: string): string | null { : head } -/** One-line plan update row (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 }: ToolRowProps) { +/** One-line plan update row (the whole row toggles the call's Input/Output + * sections, ToolRow's unified expand). 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, inspect }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? model.summary @@ -55,7 +56,10 @@ export function TodoRow({ toolName, block }: ToolRowProps) { title="更新任务清单" summary={summary} body={model.body} + output={model.output} + errorSummary={model.errorSummary} state={model.state} + inspect={inspect} /> ) } diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 2d143764bb..7201b89bc3 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -134,7 +134,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => { }) describe('terminal card assembly', () => { - it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => { + it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => { const runtime = await bench([ bashResult(3, 'c-keyed'), // An unregistered tool with terminal views: GenericToolCard fallback. @@ -142,15 +142,20 @@ describe('terminal card assembly', () => { ]) const view = runtime.renderRoot() - // Keyed BashRow renders the card residently (no expand gesture). - const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement - expect(keyed?.querySelector('[data-terminal]')).not.toBeNull() + // Keyed BashRow: collapsed by default, the whole summary row is the toggle. + const keyedRow = view.container.querySelector('[data-sample="bash-global"]') + const keyed = keyedRow?.parentElement + expect(keyed?.querySelector('[data-terminal]')).toBeNull() + fireEvent.click(keyedRow!) + await waitFor(() => { + expect(keyed!.querySelector('[data-terminal]')).not.toBeNull() + }) - // Fallback row: card appears only after its expand control. + // Fallback row: same unified expand interaction. const fallback = view.container.querySelector('[data-tool="fx-bash"]') expect(fallback).not.toBeNull() expect(fallback!.querySelector('[data-terminal]')).toBeNull() - fireEvent.click(fallback!.querySelector('button[aria-expanded]')!) + fireEvent.click(fallback!.querySelector('[data-expandable]')!) await waitFor(() => { expect(fallback!.querySelector('[data-terminal]')).not.toBeNull() }) 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 fc5a94af61..9dc75715c4 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -107,11 +107,32 @@ describe('MessageItem arms', () => { expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) - it('context and unknown nodes render their JSON rows', () => { - const ctxView = render( + it('context nodes render a title-only tool row that expands the injected text without labels', () => { + const view = render( + , + ) + const row = view.getByRole('button', { name: /上下文注入/ }) + // Title-only collapsed row: the injected text stays behind the expand. + expect(view.queryByText(/memory line one/)).toBeNull() + fireEvent.click(row) + expect(view.getByText(/second line/)).toBeTruthy() + // Label-less card: the injection is ambient context, not a call's IN payload. + expect(view.queryByText('IN')).toBeNull() + }) + + it('a context node without pure text expands to the full JSON payload', () => { + const view = render( , ) - expect(ctxView.getByText(/上下文注入/)).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: /上下文注入/ })) + expect(view.getByText(/"source": null/)).toBeTruthy() + }) + + it('unknown nodes render their JSON rows', () => { const unknownView = render( , ) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index bd1ac63f1f..3f04f9ad70 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -203,7 +203,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent) .toContain('Unmount temporary Plugindyn-2') - fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + fireEvent.click(mounted!.querySelector('[data-expandable]')!) expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) }) @@ -211,8 +211,8 @@ describe('run_code sub-calls through the real chat machinery', () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) const view = mountApp(b.slots) - // The code row is expandable via its leading control (body = the program). - const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]') + // The code row is expandable via the whole summary row (body = the program). + const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') expect(toggle).not.toBeNull() fireEvent.click(toggle!) // Shiki splits the program into token spans inside one
      :
      diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts
      index 50ec5542ef..17d90cfbde 100644
      --- a/packages/client/ui-conversation/tests/chat-store.spec.ts
      +++ b/packages/client/ui-conversation/tests/chat-store.spec.ts
      @@ -12,7 +12,7 @@ beforeEach(() => {
       describe('createChatStore', () => {
         it('init shape: empty selection/draft/view', () => {
           const store = createChatStore().create()
      -    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
      +    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
         })
       
         it('actions cover the declared write set', () => {
      @@ -30,6 +30,11 @@ describe('createChatStore', () => {
       
           store.actions.setView('chat')
           expect(store.store.getSnapshot().view).toBe('chat')
      +
      +    store.actions.setInspect({ callId: 'c1' })
      +    expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
      +    store.actions.setInspect(null)
      +    expect(store.store.getSnapshot().inspect).toBeNull()
         })
       
         it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
      diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
      index 546b4a18e8..e4e5af28d0 100644
      --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
      +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
      @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
       
       afterEach(cleanup)
       import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
      -import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
      +import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
       import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
       import { ToolRow } from '../src/client/chat/ToolRow.tsx'
       import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
      @@ -102,6 +102,29 @@ describe('tool-call-model', () => {
             .toBe('{\n  "code": ""\n}')
         })
       
      +  it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
      +    expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
      +    expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
      +      .toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
      +    expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
      +      .toBe('ToolError: denied')
      +    expect(resultText(result({ content: [] }))).toBe('')
      +  })
      +
      +  it('derives output from the settled result and null while running or blank', () => {
      +    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
      +    expect(toolRowModel('bash', running()).output).toBeNull()
      +    expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
      +  })
      +
      +  it('derives errorSummary as the first output line on error rows only', () => {
      +    const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
      +    expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
      +    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
      +    expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
      +    expect(toolRowModel('bash', running()).errorSummary).toBeNull()
      +  })
      +
         it('gives Cordis lifecycle tools action titles over their generic variants', () => {
           expect(toolRowModel('cordis_inspect', running({
             name: 'cordis_inspect',
      @@ -144,14 +167,15 @@ describe('ToolRow', () => {
           expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
         })
       
      -  it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
      +  it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
           const view = render()
      -    fireEvent.click(view.container.querySelector('button')!)
      +    fireEvent.click(view.getByRole('button'))
           expect(view.queryByTestId('tool-icon')).toBeNull()
           expect(view.container.querySelector('svg')).not.toBeNull()
      -    expect(view.queryByText('List files')).toBeNull()
      +    expect(view.getByText('List files')).toBeTruthy()
           expect(view.getByText(/"a": 1/)).toBeTruthy()
      -    fireEvent.click(view.container.querySelector('button')!)
      +    expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
      +    fireEvent.click(view.getByRole('button'))
           expect(view.queryByTestId('tool-icon')).not.toBeNull()
           expect(view.getByText('List files')).toBeTruthy()
         })
      @@ -162,16 +186,20 @@ describe('ToolRow', () => {
           expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
           const errorView = render()
           expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
      +    // The dot rides the idle slot, so an expandable error row keeps the
      +    // icon→chevron hover preview instead of losing it with the icon.
      +    expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
         })
       
      -  it('non-expandable rows render a passive leading slot', () => {
      +  it('non-expandable rows render a passive leading slot and no row button', () => {
           const view = render()
      -    expect(view.container.querySelector('button')).toBeNull()
      +    expect(view.queryByRole('button')).toBeNull()
      +    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
           expect(view.queryByTestId('tool-icon')).not.toBeNull()
         })
       
      -  it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
      -    const view = render()
      +  it('the row toggles from Enter and Space, ignoring other keys', () => {
      +    const view = render()
           const row = view.getByRole('button')
           fireEvent.keyDown(row, { key: 'Tab' })
           expect(row.getAttribute('aria-expanded')).toBe('false')
      @@ -181,32 +209,31 @@ describe('ToolRow', () => {
           expect(row.getAttribute('aria-expanded')).toBe('false')
         })
       
      -  it('a non-expandable expandOnRowClick row exposes no row button', () => {
      -    const view = render()
      -    expect(view.queryByRole('button')).toBeNull()
      -  })
      -
      -  it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
      +  it('file rows expand from the row while the path link opens without toggling', () => {
           const open = vi.fn()
           const view = render(
             ,
           )
      +    const row = view.getByRole('button', { name: /Read/ })
      +    // Path click opens the file and leaves the row collapsed.
           fireEvent.click(view.getByText('src/a.ts'))
           expect(open).toHaveBeenCalledWith('src/a.ts')
      -    // Only the path link is a button — no args-expand affordance on file rows.
      -    expect(view.container.querySelectorAll('button')).toHaveLength(1)
      -    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
      -    expect(view.queryByText(/"a": 1/)).toBeNull()
      +    expect(row.getAttribute('aria-expanded')).toBe('false')
      +    // Row click (outside the link) expands the args body.
      +    fireEvent.click(row)
      +    expect(row.getAttribute('aria-expanded')).toBe('true')
      +    expect(view.getByText(/"a": 1/)).toBeTruthy()
         })
       
      -  it('a single-file path disables expand even when onOpenFile is absent', () => {
      +  it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
           const view = render(
             ,
           )
           expect(view.container.querySelector('button')).toBeNull()
      -    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
      -    fireEvent.click(view.getByText('作文.md'))
      -    expect(view.queryByText(/"a": 1/)).toBeNull()
      +    const row = view.getByRole('button')
      +    fireEvent.click(row)
      +    expect(row.getAttribute('aria-expanded')).toBe('true')
      +    expect(view.getByText(/"a": 1/)).toBeTruthy()
         })
       
         it('non-file rows do not open anything when the summary is clicked', () => {
      @@ -215,6 +242,75 @@ describe('ToolRow', () => {
           fireEvent.click(view.getByText('List files'))
           expect(open).not.toHaveBeenCalled()
         })
      +
      +  it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
      +    const view = render(
      +      ,
      +    )
      +    expect(view.getByText('boom')).toBeTruthy()
      +    expect(view.queryByText('List files')).toBeNull()
      +    fireEvent.click(view.getByRole('button'))
      +    expect(view.getByText(/detail/)).toBeTruthy()
      +    expect(view.container.querySelector('[data-error]')).not.toBeNull()
      +  })
      +
      +  it('an error row without an error summary keeps the args summary', () => {
      +    const view = render()
      +    expect(view.getByText('List files')).toBeTruthy()
      +  })
      +
      +  it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
      +    const open = vi.fn()
      +    const view = render(
      +      ,
      +    )
      +    fireEvent.click(view.getByText('cannot overwrite'))
      +    expect(open).not.toHaveBeenCalled()
      +    // The failure line renders as plain text, not the underlined link button.
      +    expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
      +  })
      +
      +  it('the expanded body carries a hover Inspect pill that fires the callback', () => {
      +    const inspect = vi.fn()
      +    const view = render()
      +    // Collapsed: no pill.
      +    expect(view.queryByText('Inspect')).toBeNull()
      +    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
      +    const pill = view.getByText('Inspect')
      +    fireEvent.click(pill)
      +    expect(inspect).toHaveBeenCalledTimes(1)
      +    // The pill click must not collapse the row (body is a .row sibling).
      +    expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
      +  })
      +
      +  it('no inspect callback, no pill', () => {
      +    const view = render()
      +    fireEvent.click(view.getByRole('button'))
      +    expect(view.queryByText('Inspect')).toBeNull()
      +  })
      +
      +  it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
      +    const both = render()
      +    fireEvent.click(both.getByRole('button'))
      +    expect(both.getByText('IN')).toBeTruthy()
      +    expect(both.getByText('OUT')).toBeTruthy()
      +    expect(both.getByText('result text')).toBeTruthy()
      +    cleanup()
      +    const inputOnly = render()
      +    fireEvent.click(inputOnly.getByRole('button'))
      +    expect(inputOnly.getByText('IN')).toBeTruthy()
      +    expect(inputOnly.queryByText('OUT')).toBeNull()
      +    cleanup()
      +    const outputOnly = render()
      +    fireEvent.click(outputOnly.getByRole('button'))
      +    expect(outputOnly.queryByText('IN')).toBeNull()
      +    expect(outputOnly.getByText('OUT')).toBeTruthy()
      +    expect(outputOnly.getByText('only out')).toBeTruthy()
      +  })
       })
       
       describe('ThinkRow', () => {
      @@ -234,6 +330,21 @@ describe('ThinkRow', () => {
           fireEvent.click(view.getByText('Think'))
           expect(row.getAttribute('aria-expanded')).toBe('false')
         })
      +
      +  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
      +    const view = render(
      +      ,
      +    )
      +    fireEvent.click(view.getByText('Think'))
      +    // The summary (first line) is gone from the row; only the body carries it.
      +    expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
      +    expect(view.queryByText('IN')).toBeNull()
      +    expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
      +    expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
      +  })
       })
       
       describe('GenericToolCard', () => {
      @@ -283,6 +394,14 @@ describe('GenericToolCard', () => {
           expect(view.container.querySelector('svg')).not.toBeNull()
         })
       
      +  it('passes the owner inspect callback through to the expanded row pill', () => {
      +    const inspect = vi.fn()
      +    const view = render()
      +    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
      +    fireEvent.click(view.getByText('Inspect'))
      +    expect(inspect).toHaveBeenCalledTimes(1)
      +  })
      +
         it('file-path summary click reaches openFile; bash summary does not', () => {
           const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
           const fileView = render()
      diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
      index ccf940d430..2d1e4b25e5 100644
      --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
      +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
      @@ -112,7 +112,7 @@ describe('keyed toolview hole through the real machinery', () => {
           expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
             .toContain('Unmount temporary Plugindyn-2')
       
      -    fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
      +    fireEvent.click(mounted!.querySelector('[data-expandable]')!)
           expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
           await b.runtime.dispose()
         })
      diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
      index c0cb4dcb78..1462b2f9c5 100644
      --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
      +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
      @@ -94,6 +94,13 @@ function makeHarness(init?: Partial) {
         const openDetails = vi.fn<(t: SelectionTarget) => void>()
         const openFile = vi.fn<(path: string) => void>()
         const loadOlder = vi.fn()
      +  const inspectCall = vi.fn<(callId: string) => void>()
      +  // In-memory scroll memory matching the apply.ts per-session map contract.
      +  let savedScrollTop: number | null = null
      +  const chatScroll = {
      +    save: (top: number | null) => { savedScrollTop = top },
      +    read: () => savedScrollTop,
      +  }
         // Selection rides the REAL chat store (same construction path as
         // production; the view reads it through the PropsStore useStore share).
         // renderSlot stub renders the render-site fallback (an empty keyed ledger:
      @@ -120,9 +127,11 @@ function makeHarness(init?: Partial) {
           openDetails,
           openFile,
           loadOlder,
      +    inspectCall,
      +    chatScroll,
         }
         const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
      -  return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
      +  return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, setSelection }
       }
       
       describe('chat-flow derivation', () => {
      @@ -194,6 +203,16 @@ describe('ChatView', () => {
           expect(view.getByText('run a')).toBeTruthy()
         })
       
      +  it('the expanded row Inspect pill hands the call id to inspectCall', () => {
      +    const h = makeHarness({
      +      nodes: [toolResult(3, 'a')],
      +    })
      +    const view = render()
      +    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
      +    fireEvent.click(view.getByText('Inspect'))
      +    expect(h.inspectCall).toHaveBeenCalledWith('a')
      +  })
      +
         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)] })
      @@ -280,11 +299,11 @@ describe('ChatView', () => {
           expect(rowRenders).toBe(afterMount)
         })
       
      -  it('tool row expands to the args body via the leading slot toggle', () => {
      +  it('tool row expands to the args body via the whole-row toggle', () => {
           const h = makeHarness({ nodes: [toolResult(3, 'a')] })
           const view = render()
           expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
      -    fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
      +    fireEvent.click(view.container.querySelector('[data-expandable]')!)
           expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
         })
       
      @@ -407,6 +426,55 @@ describe('ChatView', () => {
           }
         })
       
      +  it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
      +    const host = document.createElement('div')
      +    host.setAttribute('data-conversation-scroll', '')
      +    Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
      +    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
      +    Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
      +    document.body.appendChild(host)
      +    try {
      +      const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
      +      // Fresh open (nothing saved): the bottom jump stands.
      +      const view = render(, { container: host })
      +      expect(host.scrollTop).toBe(2000)
      +      // Reader scrolls up; the position is recorded continuously.
      +      host.scrollTop = 100
      +      fireEvent.scroll(host)
      +      // View-tab switch away and back: the view unmounts, then remounts.
      +      view.rerender(
      ) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(100) + // The restored position is above the floor: follow stays disarmed. + expect(view.getByLabelText('回到底部')).toBeTruthy() + } finally { + host.remove() + } + }) + + it('a remount while pinned to the bottom keeps the bottom jump', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render(, { container: host }) + // At the bottom: the scroll event records the pinned state (null). + fireEvent.scroll(host) + expect(h.chatScroll.read()).toBeNull() + view.rerender(
      ) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(2000) + } finally { + host.remove() + } + }) + it('paging button loads older and shows its busy label', () => { const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) const view = render() diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx index 70162105a9..4559618a8a 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.spec.tsx @@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => { // ...and a re-created same-id session starts from a FRESH instance. const reborn = storeFor(b, 'conversation.session', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 968c1e4461..ef470162f6 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -13,7 +13,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts' +import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts' import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' @@ -87,6 +87,19 @@ describe('terminalCardModel', () => { }))?.card.signal).toBe('SIGTERM') }) + it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => { + // isError stays false on a failing command (the exit status is result + // data), so this predicate is the row's only failure signal. + expect(terminalFailed(terminalCardModel(settled({ + resultView: resultTerminal({ exitCode: 2 }), + }))!)).toBe(true) + expect(terminalFailed(terminalCardModel(settled({ + resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, + }))!)).toBe(true) + expect(terminalFailed(terminalCardModel(settled())!)).toBe(false) + expect(terminalFailed(terminalCardModel(running())!)).toBe(false) + }) + it('takes the result view\'s replacement title over the pending one', () => { // The presentation contract defines a result title as REPLACING the pending // title, so a tool that rewrites it at settle time must win here. @@ -221,36 +234,39 @@ describe('chat row terminal body', () => { callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), }) - it('the expanded body is the command output, capped tighter than the panel', () => { - expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16) + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('the expanded body is the command output inside the row scroll container', () => { const view = render() // Collapsed: the one-line summary row only, no output. expect(view.getByText('List files')).toBeTruthy() expect(view.queryByText(/a\.ts/)).toBeNull() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() expect(view.getByText('ls -la')).toBeTruthy() // The args JSON body the generic path would have shown is gone. expect(view.queryByText(/"command"/)).toBeNull() }) - it('the cap collapses a long output inside the row, expandable in place', () => { - const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`) + it('a long output renders in full — the scroll container replaces the middle collapse', () => { + const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`) const view = render() - fireEvent.click(view.container.querySelector('button')!) - expect(view.getByText('… 其余 3 行')).toBeTruthy() - expect(view.queryByText('line-5')).toBeNull() - fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' })) + toggleRow(view) expect(view.getByText('line-5')).toBeTruthy() + expect(view.getByText('line-19')).toBeTruthy() + expect(view.queryByText(/其余/)).toBeNull() }) it('renders a multi-line command as one prompt row per line', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) const rows = view.container.querySelectorAll('[class^="_promptLine_"]') expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done']) // Still one dot for the call, on the first row. @@ -275,14 +291,14 @@ describe('chat row terminal body', () => { callView: callTerminal({ description: 'Terminal 3' }), }))} />) expect(view.getByText('Terminal 3')).toBeTruthy() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.container.querySelector('[data-terminal]')).not.toBeNull() expect(view.getByText('Terminal 3')).toBeTruthy() }) it('a running terminal call expands to the prompt line with no output yet', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('ls -la')).toBeTruthy() expect(view.queryByText('复制')).toBeNull() // The card states its own run state: a running command reads as running @@ -294,7 +310,7 @@ describe('chat row terminal body', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText(/"command"/)).toBeTruthy() }) @@ -303,9 +319,16 @@ describe('chat row terminal body', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() }) + + it('a failing exit status surfaces as the collapsed row\'s error state', () => { + const view = render() + expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error') + }) }) describe('BashRow terminal card', () => { @@ -321,14 +344,17 @@ describe('BashRow terminal card', () => { sessionId: SID, useSessions: bindSnapshotSelector(list()), } as unknown as ToolRowProps) - it('renders the command output under the summary row, without an expand gesture', () => { + it('collapses to the summary row; the whole row toggles the command output', () => { const view = render() expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + fireEvent.click(view.container.querySelector('[data-expandable]')!) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() - // The card's controls are the row's only interactions: a bash row is not a - // path link and no longer a details-panel target, so nothing here navigates. - expect(view.container.querySelector('[data-clickable]')).toBeNull() expect(view.getByText('复制')).toBeTruthy() + // Collapse back in place: the summary row returns, the card unmounts. + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.queryByText(/a\.ts/)).toBeNull() + expect(view.getByText('List files')).toBeTruthy() }) // The row's leading StateDot and the card's run-state dot describe the same @@ -337,13 +363,22 @@ describe('BashRow terminal card', () => { it('agrees with the summary row about the run state', () => { const runningView = render() expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running') + fireEvent.click(runningView.container.querySelector('[data-expandable]')!) expect(runStateOf(runningView.container)).toBe('ongoing') cleanup() const settledView = render() expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok') + fireEvent.click(settledView.container.querySelector('[data-expandable]')!) expect(runStateOf(settledView.container)).toBe('done') }) + it('a failing exit status surfaces as the collapsed row\'s error state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error') + }) + it('shows the terminal presenter\'s description instead of the args summary', () => { // `terminal_send`-style presenters author a description the args do not // repeat; the contract puts it above the card, which is this row's summary. diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 704ea0e808..0d3ff07d3b 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -7,6 +7,10 @@ .block { --dsl-terminal-radius: 12px; --dsl-terminal-line-height: 22px; + /* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font + pattern): a surface wanting the smaller code size rebinds this together + with --dsl-terminal-line-height on its own container. */ + --dsl-terminal-font: var(--dsw-font-markdown-code-block); /* The card's own left inset, holding the run-state dot in a column of its own so it never competes with the commands for horizontal space. */ --dsl-terminal-gutter: 30px; @@ -22,26 +26,49 @@ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-markdown-code-block); border-radius: var(--dsl-terminal-radius); + /* Clip the banner to the card's own radius: when a consumer adds a border, + the banner's equal corner radius no longer nests inside it and leaves a + notch at the corner. Nothing inside renders out of the box. */ + overflow: hidden; } -/* Top-aligned: the status pill and copy control stay on the first prompt row - however many command lines the card carries. */ +/* The status pill and copy control top-align to the FIRST prompt row (their + heights are capped to the prompt line, so on a multi-line command they sit + with the first command instead of floating mid-banner). */ .header { display: flex; align-items: flex-start; gap: 12px; - /* Pulled back across the card's gutter padding so the banner background and - its top-left radius span the FULL surface, then re-inset by the same amount - so the prompt text and the dot keep their positions. A plain block child - only reaches the content box, which left the gutter column painted in the - body color and drew the card's top-left corner in it — invisible in the - light theme, where banner and body share a token, and visible in the dark - one, where they do not. */ + /* Pulled back across the card's gutter padding so the banner spans the FULL + surface, then re-inset by the same amount so the prompt text and the dot + keep their positions. The banner shares the card's own surface (no banner + token): the l2 divider below is the section boundary. */ margin-left: calc(-1 * var(--dsl-terminal-gutter)); padding: 9px 14px 9px var(--dsl-terminal-gutter); - background: var(--dsw-alias-markdown-code-block-banner); border-top-left-radius: var(--dsl-terminal-radius); border-top-right-radius: var(--dsl-terminal-radius); + /* A long multi-line command scrolls inside the banner (same cap as the + IN/OUT card's sections) instead of pushing the output off screen. */ + max-height: 150px; + overflow-y: auto; +} + +/* Banner scrollbar floats off the card edge like the output's. */ +.header::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.header::-webkit-scrollbar-track { + margin: 6px; +} + +/* Full-width l2 hairline between the command banner and the body — the same + divider the IN/OUT card draws between its sections. A running card is + banner-only, so it draws none. */ +.block:not([data-running]) .header { + border-bottom: 1px solid var(--dsw-alias-border-l2); } /* One row per command line. The prompt column is the only element allowed to @@ -51,7 +78,7 @@ flex-direction: column; min-width: 0; flex: 1; - font: var(--dsw-font-markdown-code-block); + font: var(--dsl-terminal-font); } .promptLine { @@ -100,27 +127,59 @@ white-space: pre; } +/* Capped to the prompt's line height (Pill's own 24px height would exceed a + smaller-font prompt row and stretch the banner). Sticky against the + banner's own scroll so the pill and the copy control stay in reach while a + long command scrolls underneath. */ .status { flex: none; + position: sticky; + top: 0; + height: var(--dsl-terminal-line-height); color: var(--dsw-alias-state-error-primary); } .copyButton { flex: none; - background-color: transparent; + position: sticky; + top: 0; + /* Card surface, not transparent: the control is sticky over the banner's + own scroll, so scrolled command text must not bleed through it. */ + background-color: var(--dsw-alias-markdown-code-block); border: none; padding: 0; margin: 0; color: var(--dsw-alias-label-secondary); cursor: pointer; font: var(--dsw-font-xs-13); + line-height: var(--dsl-terminal-line-height); } +/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller + would run its scrollbar over the banner (and the copy control), while here + the banner stays pinned and the bar sits inside the output's right padding. + Unset, the max-height is none and the auto overflow never engages. */ .output { + max-height: var(--dsl-terminal-output-max-height, none); padding: 12px 14px 12px 0; - font: var(--dsw-font-markdown-code-block); + font: var(--dsl-terminal-font); overflow-x: auto; - overflow-y: hidden; + overflow-y: auto; +} + +/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px + off the card edge: a transparent border clips the thumb inward so it never + hugs the rounded corner. */ +.output::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +/* Track end-margins keep the thumb's travel out of the card's rounded + corners in both directions. */ +.output::-webkit-scrollbar-track { + margin: 6px; } /* No wrapping, no word-break: alignment is the payload of terminal output. */ @@ -147,6 +206,6 @@ .empty { padding: 12px 14px 12px 0; - font: var(--dsw-font-markdown-code-block); + font: var(--dsl-terminal-font); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index c707711f69..1b30a7f58f 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -35,7 +35,7 @@ export interface TerminalBlockProps { signal?: string | undefined /** The command is still running: the block shows the prompt line alone. */ running?: boolean | undefined - /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */ + /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 7b5cc1d1d8..c96aefa0ef 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -139,7 +139,11 @@ .option { display: flex; - align-items: center; + /* flex-start, not center: with a wrapped description the indicator must + stay on the FIRST line (centering drifts it down the taller copy block). + The 8px padding makes a single-line row 40px exactly, so nothing reads + as top-heavy; .number/.checkbox re-center against the first line box. */ + align-items: flex-start; gap: 8px; width: 100%; min-height: 40px; @@ -148,7 +152,7 @@ intrinsic height, and centered content then paints outside the row box — over the title and the next row. Overflow belongs to .options. */ flex-shrink: 0; - padding: 6px 12px 6px 8px; + padding: 8px 12px 8px 8px; border: 1px solid transparent; border-radius: 12px; background: transparent; @@ -180,6 +184,9 @@ flex: 0 0 20px; width: 20px; height: 20px; + /* (24px first-line box − 20px indicator) / 2: centers the indicator against + the first text line under the row's flex-start alignment. */ + margin-top: 2px; border-radius: 6px; background: var(--dsw-alias-bg-overlay); color: var(--dsw-alias-label-secondary); @@ -197,6 +204,8 @@ flex: 0 0 20px; width: 20px; height: 20px; + /* Same first-line centering as .number under flex-start alignment. */ + margin-top: 2px; } .checkbox::before { @@ -263,14 +272,16 @@ inline text input; focus or a typed draft lifts it to the selected look. */ .customRow { display: flex; - align-items: center; + /* Same first-line alignment as .option — the indicator seat carries the + 2px re-centering margin. */ + align-items: flex-start; gap: 8px; width: 100%; min-height: 40px; /* Same reason as .option: the custom row is scroll content, and shrinking it pushes the inline input past the footer. */ flex-shrink: 0; - padding: 6px 12px 6px 8px; + padding: 8px 12px 8px 8px; border: 1px solid transparent; border-radius: 12px; transition: background-color 120ms ease, border-color 120ms ease; @@ -378,9 +389,7 @@ .option, .customRow { - align-items: flex-start; - gap: 8px; - padding: 6px; + padding: 8px 6px; } .footer { diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index aea8d9cb83..e062b4ba93 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -131,6 +131,14 @@ body { --dsw-font-markdown-code-block-font-size: 13px; --dsw-font-markdown-code-block-font-style: normal; + /* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */ + --dsw-font-markdown-code-block-small: 12px/18px var(--ds-font-family-code); + --dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code); + --dsw-font-markdown-code-block-small-font-weight: 400; + --dsw-font-markdown-code-block-small-line-height: 18px; + --dsw-font-markdown-code-block-small-font-size: 12px; + --dsw-font-markdown-code-block-small-font-style: normal; + --dsw-font-xl-24: 600 24px/32px var(--dsw-font-family); --dsw-font-xl-24-font-family: var(--dsw-font-family); --dsw-font-xl-24-font-weight: 600; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..a979be2642 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -235,6 +235,10 @@ export interface TrajectoryTableProps { collapsedAssistants: ReadonlySet /** Toggle tool calls under one assistant record. */ onToggleAssistant: (index: number) => void + /** One-shot cross-view inspect: open and scroll to this call's record. */ + inspectCallId?: string | null + /** Acknowledge a consumed (or unresolvable) inspect request. */ + onInspectApplied?: (() => void) | undefined } /** One request identity paired with its session-global number. */ @@ -1402,6 +1406,8 @@ export function TrajectoryTable({ onToggleTurn, collapsedAssistants, onToggleAssistant, + inspectCallId = null, + onInspectApplied, }: TrajectoryTableProps) { const [selectedIndex, setSelectedIndex] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) @@ -1574,8 +1580,37 @@ export function TrajectoryTable({ if (target !== undefined) openRecordSummary(target) } + // Cross-view inspect handoff: resolve the requested call to its record, + // open its summary, and remember the row to scroll once the un-collapsed + // ledger has rendered. Not-found leaves the request pending (`turns` in the + // deps retries as history pages in); the ack clears the store field. + const rootRef = useRef(null) + const pendingScrollIndex = useRef(null) + const openRecordSummaryRef = useRef(openRecordSummary) + openRecordSummaryRef.current = openRecordSummary + useEffect(() => { + if (inspectCallId === null) return + const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId) + if (target === undefined) return + openRecordSummaryRef.current(target) + pendingScrollIndex.current = target.cell.index + onInspectApplied?.() + }, [inspectCallId, turns, onInspectApplied]) + useEffect(() => { + const index = pendingScrollIndex.current + if (index === null) return + const row = rootRef.current + ?.querySelector(`tr[data-record-index="${index}"]`) + if (row === undefined || row === null) return + pendingScrollIndex.current = null + /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ + if (typeof row.scrollIntoView === 'function') { + row.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }) + return ( -
      +
      { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index c7f4e193da..6946f747ff 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -134,7 +134,7 @@ function searchMatches( } export function TrajectoryView({ - useHistory, loadAllHistory, + useHistory, loadAllHistory, inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = @@ -502,6 +502,8 @@ export function TrajectoryView({ onToggleTurn={toggleTurn} collapsedAssistants={collapsedAssistants} onToggleAssistant={toggleAssistant} + inspectCallId={inspect?.callId ?? null} + onInspectApplied={onInspectDone} />
      diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index fb21ca45b7..8d5b321064 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -187,4 +187,50 @@ describe('TrajectoryTable', () => { expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy() expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy() }) + + const CALL_TURNS: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'bash · {"command":"pwd"}', + inputDetail: '{"command":"pwd"}', + callId: 'call-1', + timeSeconds: 0.1, + }], + }], + }] + + it('an inspect target opens the matching record and acknowledges once', () => { + const onInspectApplied = vi.fn() + render( + , + ) + + expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true') + expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() + expect(onInspectApplied).toHaveBeenCalledOnce() + }) + + it('an unmatched inspect target stays pending without acknowledgement', () => { + const onInspectApplied = vi.fn() + render( + , + ) + + expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false') + expect(onInspectApplied).not.toHaveBeenCalled() + }) }) From 60a3cbeb8ee4c11c0352507f7851f60d814f790f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:26:37 +0800 Subject: [PATCH 42/85] fix(client-connection): drop content copy from fixture web views after base merge The base (feat/web-presenter) removed the content field from WebSearchResultView/WebFetchResultView: the web card carries no content copy and a capability-less UI falls back to the raw tool/result content (web-result-card note). The fixture still constructed both web result views with a content field, failing the e2e build with TS2353. Drop the content spread and the stale Omit key; the fixture already emits the same text as the tool/result content the fallback path renders. --- .../client/connection/src/client/fixture.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cc8332d081..587f873b74 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -143,9 +143,9 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind' | 'content'> = { +const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', sources: [ { @@ -168,7 +168,7 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind' | 'content'> = { +const WEB_FETCH_RESULT: Omit, 'card' | 'kind'> = { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200, truncated: false, @@ -402,14 +402,15 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR const call = presentCall(name, argsRaw) if (call === undefined) return undefined // The web tools keep a generic pending card, so their result card is chosen - // by tool name rather than by the pending card tag: the structured `web` - // card the frontend consumes, with the model-facing text kept as the - // capability-less fallback content. + // by tool name rather than by the pending card tag: the structured `web` card + // the frontend consumes. The view carries no `content` copy (per the contract + // and the web-result-card note); a capability-less UI falls back to the raw + // `tool/result` content, which this fixture emits from `resultText`. if (name === 'web_search') { - return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT, content: text(resultText) } + return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } } if (name === 'web_fetch') { - return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT, content: text(resultText) } + return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } } switch (call.card) { case 'terminal': From 1fd6b5a107124470219687b9a761f40640257db8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:36:42 +0800 Subject: [PATCH 43/85] =?UTF-8?q?fix(web):=20diff=20card=20review=20?= =?UTF-8?q?=E2=80=94=20TUI=20parity,=20path-header=20overlap,=20double-res?= =?UTF-8?q?olve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the TUI diff footer onto the same terminator rule and distinct-path count the Web DiffBlock uses (a trailing newline terminates its line; two hunks in one file read as 1 file), so the two front ends' `+A -R · N file(s)` footers agree. Reserve space in the diff path header for the floating copy button so a long path no longer scrolls under it. Pass the tool's raw path to the injected openFile (which already resolves against cwd) instead of resolving twice. Rename the shared block-body CSS class to a card-neutral cardBody so a terminal-spacing tweak cannot silently move the diff card. Add a same-file two-hunk TUI unit test and an assembled built-boot assertion that the write turn renders +1 -0 · 1 file end to end. --- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 5 ++- .../feature/2026-07-30-web-diff-card.zh.md | 5 ++- apps/web/tests/built-boot.snapshot.ts | 11 ++++++ .../src/client/chat/ToolRow.module.css | 15 ++++---- .../src/client/chat/ToolRow.tsx | 11 +++--- .../client/skeleton/DetailsPanel.module.css | 7 ++-- .../src/client/skeleton/DetailsPanel.tsx | 4 +- .../client/toolviews/file-mutation-row.tsx | 9 +++-- .../ui-primitives/src/DiffBlock.module.css | 6 ++- .../client/ui-primitives/src/DiffBlock.tsx | 12 +++--- packages/ui/tui/src/components/transcript.ts | 29 +++++++++++--- packages/ui/tui/tests/tui.spec.ts | 38 +++++++++++++++++++ 13 files changed, 116 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 7ed620736c..f9b2cfa908 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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-30-web-diff-card.md -2026-07-30-web-diff-card.md: 8087ce698e65f78c7c6f51211ef00e3b0ab58ed9 -2026-07-30-web-diff-card.zh.md: d85ac1f2e13c7fb3732b327b40122076337ac538 +2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c +2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 8087ce698e..396bdbc284 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,11 +16,12 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends, with one deliberate divergence noted below (the file count): +The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends: -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths — the divergence from the TUI, whose footer uses `diffs.length` and so reads two hunks in one file as `2 files` where this reads `1 file`. +- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both. - **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. +- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry. - **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index d85ac1f2e1..afdeafa6e9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,11 +16,12 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态,仅文件计数一处刻意分歧(见下): +组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态: -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计**去重后的路径数** —— 这是与 TUI 的分歧:TUI 页脚用 `diffs.length`,同文件两个 hunk 在那里读作 `2 files`,此处读作 `1 file`。 +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 - **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 +- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。 - **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..0e1d58f965 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -108,6 +108,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) + // The write/edit turns render a real diff card through the assembled graph + // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text. + // The write turn's `hello fixture\n` proves the terminator rule end to end: a + // trailing newline terminates its line, so the footer reads `+1` (not a + // phantom `+2`) and one distinct file. + const diffCards = document.querySelectorAll('[data-diff]') + expect(diffCards.length).toBeGreaterThan(0) + const footers = [...document.querySelectorAll('[data-diff]')] + .map(card => card.textContent ?? '') + expect(footers.some(text => text.includes('+ hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) 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 e53b472c50..f1d3e7dd7e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -113,14 +113,15 @@ color: var(--dsw-alias-label-tertiary); } -/* The two block-shaped expanded bodies: the code variant's run_code program - through CodeBlock (shiki-highlighted TypeScript) and a terminal card's - command output through TerminalBlock. Both are drawn by the shared - primitive, so only the row's indentation is this file's concern — the margin - also replaces each primitive's own standalone vertical spacing with the - flow's row rhythm. */ +/* The block-shaped expanded bodies: the code variant's run_code program through + CodeBlock (shiki-highlighted TypeScript), a terminal card's command output + through TerminalBlock, and a write/edit diff through DiffBlock. All are drawn + by a shared primitive, so only the row's indentation is this file's concern — + the margin also replaces each primitive's own standalone vertical spacing with + the flow's row rhythm. Card-neutral: it carries no terminal- or diff-specific + value, so it fits every block body. */ .codeBody, -.terminalBody { +.cardBody { margin: 4px 0 4px 22px; } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 52238788d1..b89f537230 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -83,9 +83,10 @@ export function ToolRow({ // A row that names a single file keeps one interaction (open that path); // args expand is off whether or not the open callback is wired yet. A card // body (terminal or diff) still expands: only the file variants carry a - // path. A write/edit row carries both a file path and a diff card, so its - // path link and its expandable card coexist — the card expands, the summary - // stays a link. + // path. A write/edit row carries both a file path and a diff card, so both + // the path link and the expandable card are offered — the collapsed row shows + // the path link, and expanding swaps it for the card body (DisclosureRow + // renders collapsedContent only while closed). const singleFile = filePath !== undefined const fileLink = singleFile && onOpenFile !== undefined const cardBody = terminalBody !== null || diffBody !== null @@ -138,9 +139,9 @@ export function ToolRow({
      {terminalBody.description}
      )} {terminalBody !== null - ? + ? : diffBody !== null - ? + ? : variant === 'code' ? :
      {text}
      } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..994ae718cb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -101,8 +101,9 @@ font: var(--dsw-font-xs-13); } -/* The terminal card sits directly under its section label, so it drops the - primitive's standalone vertical margin; the section owns the spacing. */ -.terminal { +/* A card body (terminal or diff) sits directly under its section label, so it + drops the primitive's standalone vertical margin; the section owns the + spacing. Card-neutral: no terminal- or diff-specific value. */ +.cardBody { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index d09d3ea256..5a5e19179c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -146,12 +146,12 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u {terminal.description !== undefined && (
      {terminal.description}
      )} - + ) } const diff = diffCardModel(material.block) - if (diff !== null) return + if (diff !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
      运行中…
      diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index e777c78932..323a73e77c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -17,7 +17,7 @@ import type { Context } from 'cordis' import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' -import { resolveToolPath, toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './file-mutation-row.module.css' function leadingFor(state: ToolRowState) { @@ -63,8 +63,9 @@ function errorText(block: ToolRowProps['block']): string | null { /** * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, * with the applied diff resident below it. The summary is a path link (a file - * tool's interaction) resolved against the session cwd and opened through the - * host; the card's copy and expand controls are the row's only other actions. + * tool's interaction); the host's `openFile` resolves it against the session + * cwd, so this passes the tool's own path verbatim. The card's copy and expand + * controls are the row's only other actions. */ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { const model = toolRowModel(toolName, block, cwd) @@ -85,7 +86,7 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index 8794dbb87e..c5b79006a3 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -46,10 +46,14 @@ white-space: pre; } -/* A file header: the path in the primary tone, set apart by weight. */ +/* A file header: the path in the primary tone, set apart by weight. The copy + button floats over this first row's top-right corner, so reserve space at the + line's end for it — a long path scrolls under the button otherwise, and the + button's hit area would eat clicks on the path's tail. */ .path { color: var(--dsw-alias-label-primary); font-weight: 600; + padding-right: 56px; } /* A same-file second hunk's separator (a scattered edit), in the dim tone. */ diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 5ae28bc9b2..23c498b1d1 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -4,9 +4,10 @@ // color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors // the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads // the same across front ends: the removed side is the old text in full, the -// added side the new text in full. Output never soft-wraps — an aligned source -// line keeps its indentation and scrolls horizontally instead of folding. -// Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. +// added side the new text in full, both split on the same terminator rule, and +// the footer counts distinct paths on both ends. Output never soft-wraps — an +// aligned source line keeps its indentation and scrolls horizontally instead of +// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' @@ -68,9 +69,8 @@ const ROW_CLASS: Record = { * opens each new file; a same-file second hunk (a scattered edit) opens with a * `⋯` gap instead of repeating the path. Every old-side line counts toward * `removed` and every new-side line toward `added`. The file count is of - * DISTINCT paths, which is the one deliberate divergence from the TUI diff card: - * the TUI footer uses `diffs.length`, so two hunks in one file read there as - * `2 files`, whereas this counts the one file they belong to. + * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file + * read as `1 file` on both front ends. * @param diffs - the hunks to render. * @returns the body rows, the +/- totals, and the distinct-file count. */ diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 58d3d6a178..c9a505bbb2 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -52,15 +52,28 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +/** + * A side's content lines under the terminator rule the Web DiffBlock also + * applies: empty text is zero lines (a full deletion's `newText`, a create's + * absent `oldText`), and a single trailing newline terminates the last line + * rather than adding an empty one. An interior blank line survives. Keeping the + * two front ends on the same rule holds their `+A -R` footers in step. + */ +function diffContentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + /** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ function diffLines(diff: FileDiff, palette: Palette): string[] { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) + for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) + for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) return lines } @@ -488,15 +501,19 @@ export class ToolCardComponent implements Component { } if (view.card === 'diff') { // The header no longer names the file, so each diff keeps its own path - // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + // header. A trailing footer summarizes the change (`+A -R · N file(s)`), + // on the same terminator rule and distinct-path count the Web DiffBlock + // uses, so the two front ends' footers agree. let added = 0 let removed = 0 + const paths = new Set() const hunks = view.diffs.flatMap((diff, index) => { - if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length - added += displayText(diff.newText).split('\n').length + paths.add(diff.path) + if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length + added += diffContentLines(displayText(diff.newText)).length return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] }) - const files = view.diffs.length + const files = paths.size const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d99780dedb..c03d40f098 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4317,6 +4317,21 @@ describe('tool cards and surface replay', () => { diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], }), }, + scatteredDiff: { + name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // Two hunks in ONE file, each side ending in the terminator newline real + // write/edit content carries. The footer must read `+2 -0 · 1 file`: the + // trailing newline terminates its line rather than adding a phantom empty + // one, and the two hunks count as the single distinct path they touch. + presentCall: () => ({ + card: 'diff', + title: 'Edit src/scatter.ts', + diffs: [ + { path: 'src/scatter.ts', oldText: null, newText: 'first\n' }, + { path: 'src/scatter.ts', oldText: null, newText: 'second\n' }, + ], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), @@ -4621,6 +4636,29 @@ describe('tool cards and surface replay', () => { await dispose(result) }) + it('counts a same-file diff once and terminates its trailing newline', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'scatter edits in one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // Two hunks, one path: distinct-path count, same as the Web DiffBlock. + expect(output).toContain('· 1 file') + expect(output).not.toContain('· 2 files') + // The `first\n`/`second\n` sides each contribute exactly one added line — + // the trailing newline terminates rather than adding a phantom empty `+ `. + expect(output).toContain('+ first') + expect(output).toContain('+ second') + expect(output).toContain('+2 -0') + await dispose(result) + }) + it('drops blank rows from a terminal card result that the dim styling wraps', async () => { const blankRowTools: Record = { trailing: { From c2bce3cac6948d15a4977269c188c80b6c0e18bc Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:45:36 -0700 Subject: [PATCH 44/85] fix(client): unblock full access CI gates --- packages/client/ui-permission/src/client/index.ts | 4 ++++ vitest.config.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 2914e96f64..d5561afe32 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -72,6 +72,9 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + // This optional bundle and ui-conversation can load independently, so each + // owns the same safety copy under its own locale namespace. + /* jscpd:ignore-start */ ctx.effect(() => { const disposers = [ ctx.locale.register(ACCESS_NS, 'zh', { @@ -91,6 +94,7 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-permission: Full access confirmation dictionaries') + /* jscpd:ignore-end */ const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session diff --git a/vitest.config.ts b/vitest.config.ts index 750bb6ddf2..bc5dd85e1e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -108,6 +108,7 @@ export default defineConfig({ 'packages/client/ui-primitives/src/markdown/plain-text.ts', 'packages/client/ui-question/src/client/QuestionComposer.tsx', 'packages/client/ui-primitives/src/Menu.tsx', + 'packages/client/ui-primitives/src/RiskConfirmation.tsx', 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', From f04b35c6a43f058b052f1d074e4da18ca4424ec2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 21:49:33 +0800 Subject: [PATCH 45/85] fix(test): isolate jsdom storage on Node 26 --- .../2026-07-06-node-engine-floor.i18n.yaml | 6 ++--- .../process/2026-07-06-node-engine-floor.md | 4 +-- .../2026-07-06-node-engine-floor.zh.md | 4 +-- ...itest-jsdom-webstorage-ownership.i18n.yaml | 6 +++++ ...07-30-vitest-jsdom-webstorage-ownership.md | 26 +++++++++++++++++++ ...30-vitest-jsdom-webstorage-ownership.zh.md | 26 +++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 4 +-- scripts/run-gates.spec.ts | 17 ++++++++++++ scripts/run-gates.ts | 5 ++++ scripts/vitest-environment.compat.spec.ts | 14 ++++++++++ vitest.config.ts | 3 +++ vitest.e2e.config.ts | 2 ++ vitest.shared.ts | 5 ++++ vitest.snapshot.config.ts | 2 ++ vitest.web.config.ts | 2 ++ 15 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md create mode 100644 scripts/vitest-environment.compat.spec.ts create mode 100644 vitest.shared.ts diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 2cb7f1009d..50d3e498a5 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.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 -2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd -2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md +2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7 +2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index f1754ea7ca..ef047d885a 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -10,7 +10,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal ## Decision -Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor. +Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 24, 26]`. The primary Node 24 jobs own the complete typecheck and unit coverage inventory; every version runs focused source-worker, Zstandard, source-launch, and [jsdom storage](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) smokes without repeating that inventory. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor. Two Node features gate the source runtime: @@ -24,7 +24,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi ## Consequences - The advertised LTS branch no longer undercuts the Pi adapter dependency floor. -- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real. +- CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions. - The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents. - A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change. diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 9d376a6393..a0281addf7 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。 +将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 `['22.19', 24, 26]` 上运行 keyless CI。主要的 Node 24 任务负责整套类型检查和单元测试覆盖率任务;三个版本均运行 source-worker、Zstandard、source-launch 和 [jsdom 存储](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) 专项冒烟测试,不重复这套类型检查和覆盖率任务。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。 两个 Node 特性决定了源码运行时的门槛: @@ -24,7 +24,7 @@ Status: implemented ## 后果 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 -- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。 +- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 - 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。 diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml new file mode 100644 index 0000000000..e829874e9d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.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/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md +2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2 +2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8 diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md new file mode 100644 index 0000000000..3956a7566f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md @@ -0,0 +1,26 @@ +# Agent Note: Keep browser storage owned by jsdom in Vitest + +Status: implemented + +English | [中文](2026-07-30-vitest-jsdom-webstorage-ownership.zh.md) + +## Problem + +The supported Node range includes releases that reserve a process-wide `globalThis.localStorage`. Node 26 exposes that property as `undefined` without `--localstorage-file`; Vitest sees the reserved key and does not project jsdom's isolated `Storage` object over it. Component suites then fail before exercising product behavior, while the primary Node 24 coverage lane remains green because that runtime does not reserve the key by default. + +## Decision + +Vitest workers disable Node's process-wide Web Storage when the runtime advertises the `--webstorage` flag. The configuration passes `--no-webstorage` through each test project's `execArgv`; runtimes without that flag receive no argument. Node-environment suites therefore stay browser-free, and files selecting jsdom through `@vitest-environment jsdom` receive jsdom's isolated `localStorage`. + +The Node compatibility aggregate runs a dedicated jsdom smoke on every advertised compatibility line. It asserts both the conditional worker argument and usable storage, so a future Node or Vitest change cannot leave the primary Node 24 suite as the only signal. + +## Alternatives considered + +- **Set `NODE_OPTIONS=--no-webstorage` in package scripts or CI.** Rejected because it leaks test-runner policy into subprocesses and misses direct `pnpm exec vitest` invocations. +- **Pass `--localstorage-file` to Node.** Rejected because one process-wide persistent store has different ownership and isolation semantics from browser storage created per jsdom environment. +- **Patch `globalThis.localStorage` in setup code or guard every component test.** Rejected because setup would depend on Vitest's private jsdom projection details, while per-test guards hide a broken browser environment and duplicate policy across suites. +- **Pin tests to Node 24.** Rejected because the package engine advertises newer even Node lines and the compatibility matrix exists to expose their runtime changes. + +## Consequences + +The same `pnpm test` command works on Node releases with and without built-in Web Storage. Test workers deliberately cannot exercise Node's process-wide Web Storage; a future product need for that API requires a separate explicit test configuration rather than weakening jsdom isolation. The compatibility lane adds one focused Vitest process instead of duplicating the complete unit inventory on every Node version. diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md new file mode 100644 index 0000000000..9080ee2762 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md @@ -0,0 +1,26 @@ +# Agent Note: 在 Vitest 中将浏览器存储交由 jsdom 管理 + +Status: implemented + +[English](2026-07-30-vitest-jsdom-webstorage-ownership.md) | 中文 + +## 问题 + +受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file` 时,Node 26 将该属性暴露为 `undefined`;Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。 + +## 决策 + +当运行时声明支持 `--webstorage` 标志时,Vitest worker 会禁用 Node 的进程级 Web Storage。配置通过每个测试项目的 `execArgv` 传入 `--no-webstorage`;未声明该标志的运行时则不传入此参数。因此,Node 环境测试套件不加载浏览器环境,而通过 `@vitest-environment jsdom` 选择 jsdom 的文件会获得 jsdom 隔离的 `localStorage`。 + +Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专用的 jsdom 冒烟测试。该测试同时断言 worker 参数按条件传入且存储可用,因此未来 Node 或 Vitest 的变化不会让主要的 Node 24 测试套件成为唯一检测信号。 + +## 曾考虑的替代方案 + +- **在包脚本或 CI 中设置 `NODE_OPTIONS=--no-webstorage`。** 否决:这会将测试运行器策略传播到子进程,也无法覆盖直接调用 `pnpm exec vitest` 的情况。 +- **向 Node 传入 `--localstorage-file`。** 否决:单个进程级持久化存储与每个 jsdom 环境分别创建的浏览器存储具有不同的归属和隔离语义。 +- **在初始化代码中修改 `globalThis.localStorage`,或为每个组件测试增加保护逻辑。** 否决:初始化逻辑会依赖 Vitest 私有的 jsdom 映射细节,而逐测试添加的保护逻辑会掩盖浏览器环境损坏,并在多个测试套件中重复该策略。 +- **将测试固定在 Node 24。** 否决:包的引擎范围声明支持更新的偶数 Node 版本线,而兼容性矩阵正是为了暴露这些版本的运行时变化。 + +## 后果 + +同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage;未来若产品需要该 API,必须使用独立且显式的测试配置,而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。 diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c79b7407ab..df476101a9 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -35,9 +35,7 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - // Node 22+ exposes an experimental localStorage global that is undefined - // without --localstorage-file; only clear when a real Storage is present. - if (typeof localStorage !== 'undefined') localStorage.clear() + localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9a67e64929..624d46dfd1 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -135,6 +135,23 @@ describe('Oxlint gate', () => { }) }) +describe('Node compatibility graph', () => { + it('runs the jsdom environment smoke on every advertised Node line', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('node-compat')) + + expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({ + label: 'Vitest jsdom smoke', + args: [ + '/private/pnpm.cjs', + 'exec', + 'vitest', + 'run', + 'scripts/vitest-environment.compat.spec.ts', + ], + }) + }) +}) + describe('Node 24 consumer graph', () => { it('owns the eight-command pool and orders restored-artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 167d226ff8..b84aa903e1 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -292,6 +292,11 @@ function nodeCompatSmokeGates(): Gate[] { 'run', 'apps/cli/tests/source-launch.compat.spec.ts', ], { label: 'dsh source-launch smoke' }), + pnpmExec('vitest-jsdom-smoke', [ + 'vitest', + 'run', + 'scripts/vitest-environment.compat.spec.ts', + ], { label: 'Vitest jsdom smoke' }), ] } diff --git a/scripts/vitest-environment.compat.spec.ts b/scripts/vitest-environment.compat.spec.ts new file mode 100644 index 0000000000..fae1fc29fd --- /dev/null +++ b/scripts/vitest-environment.compat.spec.ts @@ -0,0 +1,14 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' + +describe('Vitest jsdom compatibility', () => { + it('provides isolated browser storage instead of Node process storage', () => { + if (process.allowedNodeEnvironmentFlags.has('--webstorage')) { + expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1) + } + localStorage.setItem('dsh-vitest-storage-probe', 'available') + + expect(localStorage.getItem('dsh-vitest-storage-probe')).toBe('available') + localStorage.removeItem('dsh-vitest-storage-probe') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 750bb6ddf2..460d852756 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Resolution facade shared by every plugin instance below: tsconfig.base.json // has no include, which vite-tsconfig-paths treats as match-all, so its paths @@ -62,6 +63,7 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'thread-safe', + execArgv: vitestExecArgv, // Node 24 has aborted in its CJS lexer from a macOS arm64 worker // thread. A fork contains that external runtime failure to the test // process; other hosts retain the lower-overhead thread pool. @@ -78,6 +80,7 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'process-bound', + execArgv: vitestExecArgv, pool: 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 3f9ceada28..d8e6aa53a7 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -37,6 +38,7 @@ export default defineConfig({ // entirely. plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). diff --git a/vitest.shared.ts b/vitest.shared.ts new file mode 100644 index 0000000000..506fabb380 --- /dev/null +++ b/vitest.shared.ts @@ -0,0 +1,5 @@ +/** + * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. + * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. + */ +export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index b14422d2f8..6a08eeb3eb 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,6 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -41,6 +42,7 @@ export default defineConfig({ // this (the root tsconfig is a solution file with no paths). plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], include: [ 'scripts/**/*.snapshot.ts', diff --git a/vitest.web.config.ts b/vitest.web.config.ts index fd9ca502bc..e6e1efa1f4 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Web browser lane: real host entry points, built-client interaction snapshots, // and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI @@ -18,6 +19,7 @@ export default defineConfig({ // workspace imports to source like every other lane. plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, include: [ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', From 568c564db47982980691c451e40f986e3e663b8e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:51:27 +0800 Subject: [PATCH 46/85] fix(web): update diff-card tests for the openFile no-double-resolve contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage lane caught a regression in the prior commit: FileMutationRow now passes the tool's raw path to the injected openFile (which resolves against cwd in apply.ts), but diff-card.spec still asserted the row pre-resolved. Assert on the raw path instead. Also fix the built-boot diff assertion to match on the line body and footer text — the `+ ` prefix is a CSS ::before, absent from textContent. --- apps/web/tests/built-boot.snapshot.ts | 10 +++++----- .../client/ui-conversation/tests/diff-card.spec.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 0e1d58f965..6500305cd8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -112,12 +112,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text. // The write turn's `hello fixture\n` proves the terminator rule end to end: a // trailing newline terminates its line, so the footer reads `+1` (not a - // phantom `+2`) and one distinct file. - const diffCards = document.querySelectorAll('[data-diff]') + // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so + // it is absent from textContent — assert on the line body and the footer. + const diffCards = [...document.querySelectorAll('[data-diff]')] expect(diffCards.length).toBeGreaterThan(0) - const footers = [...document.querySelectorAll('[data-diff]')] - .map(card => card.textContent ?? '') - expect(footers.some(text => text.includes('+ hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + const footers = diffCards.map(card => card.textContent ?? '') + expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 031216b9f7..bf29f889ae 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -163,11 +163,13 @@ describe('FileMutationRow diff card', () => { expect(view.getByText('复制')).toBeTruthy() }) - it('the summary is a path link that opens through the host, cwd-resolved', () => { + it('the summary is a path link that opens the tool path through the host', () => { const openFile = vi.fn() const view = render() fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) - expect(openFile).toHaveBeenCalledWith('/w/app/notes/demo.txt') + // The row passes the tool's own path; the injected openFile resolves it + // against the session cwd (apply.ts), so the row must not resolve twice. + expect(openFile).toHaveBeenCalledWith('notes/demo.txt') }) it('registers under write too, rendering a create as an added-only diff', () => { From 1ea5f0b124c51f17471278acf96bebbbe608d2a4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:24:29 +0800 Subject: [PATCH 47/85] test(tui): cover diffContentLines empty-side arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage lane flagged transcript.ts line 63 (diffContentLines' empty-text return) uncovered: the same-file diff test only fed newline-terminated sides. Add a third hunk removing a line with an empty added side (a full deletion), so the empty arm runs and the footer proves the empty side draws no `+ ` row (+2 -1 · 1 file). Raise the test's line budget so every hunk row stays visible. --- packages/ui/tui/tests/tui.spec.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 457b2c25ac..2354a6d51b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4320,16 +4320,20 @@ describe('tool cards and surface replay', () => { }, scatteredDiff: { name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], - // Two hunks in ONE file, each side ending in the terminator newline real - // write/edit content carries. The footer must read `+2 -0 · 1 file`: the - // trailing newline terminates its line rather than adding a phantom empty - // one, and the two hunks count as the single distinct path they touch. + // Three hunks in ONE file. The first two sides end in the terminator + // newline real write/edit content carries; the third removes a line and + // leaves an EMPTY added side (a full deletion), so `diffContentLines('')` + // returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing + // newline terminates its line rather than adding a phantom empty one, the + // empty side contributes no `+ ` row, and the three hunks count as the + // single distinct path they touch. presentCall: () => ({ card: 'diff', title: 'Edit src/scatter.ts', diffs: [ { path: 'src/scatter.ts', oldText: null, newText: 'first\n' }, { path: 'src/scatter.ts', oldText: null, newText: 'second\n' }, + { path: 'src/scatter.ts', oldText: 'gone\n', newText: '' }, ], }), }, @@ -4638,7 +4642,10 @@ describe('tool cards and surface replay', () => { }) it('counts a same-file diff once and terminates its trailing newline', async () => { - const result = await setup({ tools }) + // A budget past the card's row count so every hunk row stays visible (the + // collapse arithmetic is covered elsewhere); this test is about the + // terminator rule and the distinct-path footer count. + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'scatter edits in one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4649,14 +4656,17 @@ describe('tool cards and surface replay', () => { }) await tick() const output = result.terminal.output - // Two hunks, one path: distinct-path count, same as the Web DiffBlock. + // Three hunks, one path: distinct-path count, same as the Web DiffBlock. expect(output).toContain('· 1 file') - expect(output).not.toContain('· 2 files') + expect(output).not.toContain('· 3 files') // The `first\n`/`second\n` sides each contribute exactly one added line — // the trailing newline terminates rather than adding a phantom empty `+ `. expect(output).toContain('+ first') expect(output).toContain('+ second') - expect(output).toContain('+2 -0') + // The third hunk removes `gone` and leaves an empty added side, which + // contributes no `+ ` row (diffContentLines('') is zero lines). + expect(output).toContain('- gone') + expect(output).toContain('+2 -1') await dispose(result) }) From c1d88ffb7b822a40ac6e5b3aa8fd3352c1f61c32 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:24:57 +0800 Subject: [PATCH 48/85] fix(client-web): pin keyed WebRow in boot smoke, show empty-search feedback - built-boot smoke asserts [data-variant=web][data-tool=web_search/fetch] (the keyed WebRow) instead of [data-web] (which WebBlock draws even on the GenericToolCard fallback, so a silent keyed-registration failure passed). - WebBlock renders an explicit empty-state note when a search returns no answer and no sources, instead of a blank
        ; the chat row does not surface the raw result content, so the backend's 'No results found.' was otherwise invisible. - README (ui-conversation, ui-primitives) and the frontend Agent Note record the unknown-web-kind null arm, the details-panel flattened body, and the empty-search copy; pairings re-recorded. --- ...6-07-30-web-result-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-result-card-frontend.md | 6 +-- .../2026-07-30-web-result-card-frontend.zh.md | 6 +-- apps/web/tests/built-boot.snapshot.ts | 11 ++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/WebBlock.module.css | 5 ++ .../client/ui-primitives/src/WebBlock.tsx | 54 +++++++++++-------- .../ui-primitives/tests/web-block.spec.tsx | 19 +++++++ 13 files changed, 79 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index db354e75a3..dae8f18b4d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-30-web-result-card-frontend.md -2026-07-30-web-result-card-frontend.md: efead1f404879c526475a1b9f0b31f6c34978f07 -2026-07-30-web-result-card-frontend.zh.md: 2e76bf4c18f5032cd5fd9b15fe31d3c7b036b502 +2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd +2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index efead1f404..d6f4785e83 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -10,7 +10,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web ## Decision -`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), and for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves). +`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`). One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. @@ -18,7 +18,7 @@ One component draws both kinds, discriminated by `kind`. A `search` shows the an **Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. -The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance. +The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here. ## Consequences @@ -38,7 +38,7 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f `packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap. -`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. +`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index 2e76bf4c18..ed95cbe39f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、以及对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)。 +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 @@ -18,7 +18,7 @@ Status: implemented **几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 -卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片。 +卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。 ## Consequences @@ -38,7 +38,7 @@ Status: implemented `packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。 -`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 8bf48fc761..e607ead421 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -111,12 +111,13 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // The web render intent reaches the assembled boot graph: the fixture's // web_search / web_fetch turns render their keyed WebRow cards, proving the // registration, wire projection, and card rendering survive the real bundle - // path (not just the per-package src benches). Without this the whole web - // card could silently fall back to the generic row and every new unit test - // would still pass. + // path (not just the per-package src benches). The selector pins the KEYED + // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute + // WebBlock draws — the generic fallback renders the same WebBlock, so a silent + // keyed-registration failure would still satisfy a bare `[data-web]` check. await waitFor(() => { - expect(document.querySelector('[data-web="search"]')).not.toBeNull() - expect(document.querySelector('[data-web="fetch"]')).not.toBeNull() + expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull() + expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull() }, { timeout: 10_000 }) // Every bundle injected its plugin-owned style tag (the loader's CSS path). diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 5c3353695a..1bdb6eac8e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 7b39702d8828b80b50d4bc37dbc9350a674b7fdf -README.zh.md: 7f3989e6446581f8264b6fa605dd975be8518ad7 +README.md: 84671fe067917f4aa38bccfaa413404652456fc2 +README.zh.md: 7aea71890bb725c7a87d9fb12804d9477f4eed7a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7b39702d88..84671fe067 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). -A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, or a `card` tag this client version does not know. The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). 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`/`openFile`) 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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7f3989e644..7aea71890b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 -声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view,或本客户端版本不认识的 `card` 标签,它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d7eae92129..a93e506412 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 099da3ae3d4e2b45507fd18d279650ef0525f36a -README.zh.md: dc7fa5f78ea758cea75e86eefb0c06ebe92e61d2 +README.md: 4e1d85cd830fc720a3abf54b0700ea16789404ae +README.zh.md: 8b8771fa6ec714ba5c4a780135d98109f6c4d186 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 099da3ae3d..4e1d85cd83 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Web retrieval -`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
      1. `, and the expand control is a marker-less `
      2. ` so the `
          ` stays valid HTML. A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). +`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
        1. `, and the expand control is a marker-less `
        2. ` so the `
            ` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `
              ` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). ## Model Experience @@ -29,5 +29,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, `CodeBlock`'s copy control, and `WebBlock`'s source expand/collapse controls and its source-list and fetch truncation notes are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. +- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, `CodeBlock`'s copy control, and `WebBlock`'s source expand/collapse controls, its source-list and fetch truncation notes, and its empty-search note are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index dc7fa5f78e..8b8771fa6e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -13,7 +13,7 @@ ## Web 检索 -`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
            1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
            2. `,使 `
                ` 保持为合法 HTML。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 +`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
              1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
              2. `,使 `
                  ` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `
                    `(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 ## 模型体验 @@ -28,5 +28,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件、`CodeBlock` 的复制控件,以及 `WebBlock` 的来源展开/收起控件与它的来源列表与 fetch 截断提示,全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 +- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件、`CodeBlock` 的复制控件,以及 `WebBlock` 的来源展开/收起控件、它的来源列表与 fetch 截断提示、以及它的空搜索提示,全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css index 17383563bf..8aad5872ba 100644 --- a/packages/client/ui-primitives/src/WebBlock.module.css +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -91,6 +91,11 @@ font: var(--dsw-font-xs-13); } +.empty { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + /* The fetch card is a compact summary: the URL over a status/truncation row. */ .fetch { display: flex; diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index f0144bd601..804ff17dfa 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -174,34 +174,42 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M const tailCount = maxSources - headCount const head = capped ? sources.slice(0, headCount) : sources const tail = capped ? sources.slice(sources.length - tailCount) : [] + // A provider may legitimately return no answer and no sources; the chat WebRow + // does not show the raw result content, so without this the user would see an + // empty card. Mirror the backend's `No results found.` render text. + const empty = (answer === undefined || answer === '') && sources.length === 0 return (
                    {answer !== undefined && answer !== '' && (
                    )} -
                      - {head.map((source, index) => )} - {hidden > 0 && ( -
                    1. - -
                    2. - )} - {tail.map((source, index) => ( - - ))} -
                    + {empty ? ( +
                    未找到结果
                    + ) : ( +
                      + {head.map((source, index) => )} + {hidden > 0 && ( +
                    1. + +
                    2. + )} + {tail.map((source, index) => ( + + ))} +
                    + )} {truncated &&
                    来源列表已截断
                    }
                    ) diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx index b0778a636a..d681ce753b 100644 --- a/packages/client/ui-primitives/tests/web-block.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -36,6 +36,25 @@ describe('WebBlock search card', () => { expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull() }) + it('shows the empty-state note when a search returns no answer and no sources', () => { + const view = render() + expect(view.getByText('未找到结果')).toBeTruthy() + // The empty note replaces the source list, not an empty
                      . + expect(view.container.querySelector('ol')).toBeNull() + }) + + it('shows the source list, not the empty note, when a source is present', () => { + const view = render() + expect(view.container.querySelector('ol')).toBeTruthy() + expect(view.queryByText('未找到结果')).toBeNull() + }) + + it('shows the source list when an empty source list still carries an answer', () => { + const view = render() + expect(view.getByText('Just an answer')).toBeTruthy() + expect(view.queryByText('未找到结果')).toBeNull() + }) + it('labels a source by its title, and by hostname when the title is absent', () => { const view = render( Date: Thu, 30 Jul 2026 22:42:25 +0800 Subject: [PATCH 49/85] fix(web): show plan status only while active --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 3 +- ...web-input-machine-and-slash-pipeline.zh.md | 3 +- apps/web/tests/lifecycle-chrome.e2e.ts | 57 +++++++++- .../snapshots/code-mode-round/ui.expected.md | 1 - .../cordis-tool-round/ui.expected.md | 1 - .../snapshots/fresh-round-trip/ui.expected.md | 1 - .../lifecycle-chrome/hero.expected.md | 1 - .../lifecycle-chrome/plan-active.expected.md | 39 +++++++ .../lifecycle-chrome/reloaded.expected.md | 1 - .../live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../live-interactions/retry.expected.md | 1 - .../snapshots/message-actions/ui.expected.md | 1 - .../question-composer/answered.expected.md | 1 - .../queue-actions/editing.expected.md | 1 - .../snapshots/queue-actions/ui.expected.md | 1 - .../snapshots/seeded-history/ui.expected.md | 1 - .../snapshots/steering/settled.expected.md | 1 - packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 4 +- packages/client/ui-plan/README.zh.md | 4 +- packages/client/ui-plan/package.json | 2 + .../src/client/PlanModeControl.module.css | 34 +++--- .../ui-plan/src/client/PlanModeControl.tsx | 53 +++++----- packages/client/ui-plan/src/client/index.ts | 24 ++--- .../ui-plan/tests/browser-plugin.spec.ts | 18 ++-- .../ui-plan/tests/plan-mode-control.spec.tsx | 100 +++++++----------- packages/client/ui-plan/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 30 files changed, 212 insertions(+), 157 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index ea630a99f9..09ab4376b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b +2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 2793e9045f..c3deadb34d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. -- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. +- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. ### Testing discipline @@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome | | A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index e3a35c4e55..7a6988423d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 - `conversation.input.left` / `conversation.input.right`——工具行左右区。 -- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。 +- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。 ### 测试纪律 @@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome | | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 746cd37e36..97ffefccf7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -85,6 +86,60 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { + const activeScaffold = await launchWebScaffold() + const activePage = await newEnglishPage(browser) + const activeTripwire = watchConsole(activePage) + try { + await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' }) + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(activePage) + const input = activePage.locator('textarea').first() + await activePage.getByRole('button', { name: 'Commands' }).click() + const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() + await expect.poll(() => input.inputValue()).toBe('/plan ') + await input.press('Enter') + const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) + await planButton.waitFor({ timeout: 10_000 }) + const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) + await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) + const planStyle = await planButton.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-warn-label)' + probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + document.body.append(probe) + const actual = getComputedStyle(element) + const reference = getComputedStyle(probe) + const result = { + color: actual.color, + backgroundColor: actual.backgroundColor, + borderRadius: actual.borderRadius, + fontSize: actual.fontSize, + referenceColor: reference.color, + referenceBackgroundColor: reference.backgroundColor, + } + probe.remove() + return result + }) + expect(planStyle.color).toBe(planStyle.referenceColor) + expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) + expect(planStyle.borderRadius).toBe('999px') + expect(planStyle.fontSize).toBe('13px') + await planButton.click() + await expect.poll(() => planButton.count()).toBe(0) + expect(activeTripwire.pageErrors).toEqual([]) + expect(activeTripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined) + throw error + } finally { + await activePage.close() + await activeScaffold.close() + } + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -182,7 +237,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 31476daefd..7b957e5899 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4425921fdf..5abf911c83 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -50,7 +50,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 64c62f85d6..ec99a493f3 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 783964ed31..bed9df014c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md new file mode 100644 index 0000000000..8bb1351e0f --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -0,0 +1,39 @@ +- button "New session" +- button "Collapse sidebar": + - img +- button "New session": + - img + - text: New Session +- text: Workspaces +- button "Group by": + - img +- button "Create workspace": + - img +- button "Search sessions": + - img +- textbox "Search name, keywords..." +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "New Session now" [selected] +- button "Settings": + - img + - text: Settings +- text: Let's start building +- button "Choose workspace": + - img + - text: workspace + - img +- textbox "Describe what you want to build" +- button "Commands": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode on, press to turn off": Plan +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 详情 +- button "关闭详情" +- text: 点击消息流中的工具行查看详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 81f1ab608b..b323b67a30 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index f65b090a16..2b14062211 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -22,7 +22,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 0d013f819d..364cf54ddb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -15,7 +15,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 11bb665e71..c008fc4dba 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index b15a665c45..369f03c6f1 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index db0c2cfd3a..ec9a074e72 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7e67544f04..0f890e271f 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48c288909c..81144381cf 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -23,7 +23,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c520dafae9..8b6a393664 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -39,7 +39,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 1172d3ca5d..193f19ef05 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 199210a863..c1abb744cc 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/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-plan/README.md -README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540 -README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391 +README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea +README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 1d22c057b4..2f83dd738e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). +Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. @@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. -- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. +- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 183b8ef777..a026390191 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -4,7 +4,7 @@ Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 +plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 @@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 -- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 +- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a2aec47985..d7524cbc7d 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1", @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index f79e9073db..46e893aee8 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,4 @@ -/* Plan-mode toggle chip: quiet while off; the pressed state takes the - business accent pair (same token pairing as the trajectory user badge). */ +/* Active plan status follows Figma's warn-state pill. */ .wrap { display: inline-flex; @@ -10,30 +9,25 @@ .chip { display: inline-flex; align-items: center; - padding: 4px 8px; + gap: 4px; + min-width: 34px; + padding: 2px 8px; border: none; - border-radius: 8px; - background: transparent; - color: var(--dsw-alias-label-secondary); - font-size: 14px; + border-radius: 999px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-label); + font-size: 13px; + font-weight: 500; line-height: 20px; cursor: pointer; } .chip:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Hovering keeps the pressed accent: the higher-specificity hover rule above - would otherwise swap it back to the neutral hover wash. */ -.chip[aria-pressed='true'], -.chip[aria-pressed='true']:hover:not(:disabled) { - color: var(--dsw-alias-state-business-primary); - background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-warn-primary); } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-label-secondary); + outline: 2px solid var(--dsw-alias-state-warn-label); outline-offset: 2px; } @@ -42,6 +36,12 @@ cursor: default; } +.close { + display: inline-flex; + align-items: center; + color: currentColor; +} + .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 961d7435ef..a6fc74f77b 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -11,16 +12,14 @@ export type PlanChipProps = PropsRuntime<'conversation.input.plan'> & InjectFace /** - * Plan-mode toggle over the host-computed `plan` projection. The chip renders - * whenever the capability is present and reflects the effective target as its - * pressed state (`pending ? !active : active` — a folded host value, not - * client optimism, so an arriving frame corrects it). Clicking executes - * /plan or /plan off toward the opposite target. + * Plan-mode status over the host-computed `plan` projection. The chip renders + * only while the effective target is plan mode (`pending ? !active : active` + * — a folded host value, not client optimism) and executes /plan off. */ -export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) { +export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) { const plan = useProjection('plan') - const [busy, setBusy] = useState(false) - const [error, setError] = useState<{ text: string; detail: string } | null>(null) + const [leaving, setLeaving] = useState(false) + const [error, setError] = useState(null) const aliveRef = useRef(true) useEffect(() => { @@ -30,25 +29,21 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) } }, []) - // Absent capability (no plan-mode host plugin / no session yet): no seat - // content — without the capability there is nothing to toggle. if (plan === undefined) return null const target = plan.pending ? !plan.active : plan.active + if (!target) return null - const toggle = (): void => { - // No busy/locked guard: both disable the button, so no click arrives. - const on = !target - const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败' - setBusy(true) + const off = (): void => { + setLeaving(true) setError(null) - void setPlanMode(on).then((failure) => { + void exitPlanMode().then((failure) => { if (!aliveRef.current) return - setBusy(false) - setError(failure === null ? null : { text: failText, detail: failure }) + setLeaving(false) + setError(failure) }, (reason: unknown) => { if (!aliveRef.current) return - setBusy(false) - setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) }) + setLeaving(false) + setError(reason instanceof Error ? reason.message : String(reason)) }) } @@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) - {error !== null && {error.text}} + {error !== null && 退出 plan mode 失败} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 77779efb02..d85e508922 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,11 +1,11 @@ /** * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with a plan-mode toggle chip. While the - * `plan` projection is present the chip renders in both states and executes - * /plan or /plan off through `command.execute` toward the opposite target; - * an absent projection (no capability) leaves the seat empty. Reads ride the - * generic projection pair through the standard-kit `useProjection` (an absent - * key is capability absence); zero client-side plan state. + * `conversation.input.plan` seat with an active-state status chip. Plan mode + * is entered through the command source; while the projection's effective + * target is plan mode the chip renders and executes /plan off through + * `command.execute`, otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection`; zero client-side + * plan state. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,11 +18,10 @@ import { PlanChip } from './PlanModeControl.tsx' /** Injected business face of the composer plan seat. */ export interface PlanChipInjected { /** - * Switch plan mode by executing /plan (on) or /plan off. - * @param on - desired target: true enters plan mode, false leaves it. + * Leave plan mode by executing /plan off. * @returns null on admitted execution; a user-visible failure line otherwise. */ - setPlanMode: (on: boolean) => Promise + exitPlanMode: () => Promise } /** @@ -39,12 +38,11 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.register({ name: 'conversation.input.plan', inject: (sessionId: SessionId): PlanChipInjected => ({ - setPlanMode: async (on) => { - const line = on ? '/plan' : '/plan off' + exitPlanMode: async () => { const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line }) + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) if (!result.ok) return `${result.error.message}(${result.error.code})` - if (!result.value.matched) return `未知命令:${line}` + if (!result.value.matched) return '未知命令:/plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1051d52777..aa0bf35b55 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,9 +1,9 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the - * conversation-declared `conversation.input.plan` single seat with the plan - * toggle chip; the injected face executes /plan or /plan off by direction and - * folds admission outcomes into null (admitted) or a user-visible failure - * line; teardown empties the seat (HMR safety). + * conversation-declared `conversation.input.plan` single seat with the active + * plan status chip; the injected face executes /plan off and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' @@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => { .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => { + it('registers the chip, executes /plan off, and unregisters on teardown', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => { expect(entry.component).toBe(PlanChip) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) - await expect(injected.setPlanMode(false)).resolves.toBeNull() + await expect(injected.exitPlanMode()).resolves.toBeNull() expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) // Business failure folds to the composer-visible line. b.execute.mockResolvedValueOnce({ result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ result: { ok: true as const, value: { matched: false as const } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') + await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index 63f6bd7a6e..2489b53be0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,11 +1,9 @@ // @vitest-environment jsdom /** * PlanChip over the `plan` projection: nothing renders while the capability - * is absent; with the capability present the chip renders in both states with - * aria-pressed following the effective target (pending folds — /plan shows - * pressed immediately, /plan off unpressed immediately); clicking executes - * the command toward the opposite target and surfaces direction-specific - * failures while the projection still owns the displayed state. + * is absent or the effective target is the default mode; while plan mode is + * the target, the chip executes /plan off and remains visible through failures + * until the projection confirms the exit. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -18,98 +16,74 @@ afterEach(cleanup) function setup( plan: PlanProjection | undefined, - setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)), + exitPlanMode = vi.fn(() => Promise.resolve(null)), locked = false, ) { const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const useProjection = (_key: string, selector?: (v: unknown) => unknown) => bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) - const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps const view = render() - return { store, setPlanMode, view } + return { store, exitPlanMode, view } } -const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) -const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' }) +const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) describe('PlanChip', () => { - it('renders nothing while the capability is absent', () => { + it('renders nothing for an absent capability or a default-mode target', () => { const absent = setup(undefined) expect(absent.view.container.innerHTML).toBe('') + cleanup() + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') + cleanup() + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') }) - it('reflects the effective target as the pressed state, folding pending', () => { - setup({ active: false, pending: false }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') - cleanup() + it('renders the Plan status for active and pending-entry targets', () => { setup({ active: true, pending: false }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') + expect(chip().textContent).toBe('Plan') cleanup() - // /plan just ran (command/run folded, plan/mode not yet): target is plan. setup({ active: false, pending: true }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') - cleanup() - // Active with a pending exit: the target is default — already unpressed. - setup({ active: true, pending: true }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(chip().textContent).toBe('Plan') }) - it('unpressed chip executes /plan (on) once and follows the projection up', async () => { + it('executes /plan off once and follows the projection down', async () => { let resolve!: (value: string | null) => void - const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false, pending: false }, setPlanMode) - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) - expect(setPlanMode).toHaveBeenLastCalledWith(true) - // Busy while its own call is in flight. - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) resolve(null) - // The command's run record folds: target flips, the chip presses. - store.set({ value: { active: false, pending: true } }) - await waitFor(() => { - expect(onChip().getAttribute('aria-pressed')).toBe('true') - }) - }) - - it('pressed chip executes /plan off and follows the projection down', async () => { - const setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)) - const { store } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) - expect(setPlanMode).toHaveBeenLastCalledWith(false) store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() }) }) it('disables under the locked owner prop', () => { setup({ active: true, pending: false }, vi.fn(), true) - expect((onChip() as HTMLButtonElement).disabled).toBe(true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) }) - it('surfaces direction-specific admission and transport failures while staying visible', async () => { - const exitFailing = vi.fn() + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: true, pending: false }, exitFailing) - fireEvent.click(onChip()) + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') - expect(onChip()).toBeTruthy() + expect(chip()).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('network down')).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('socket closed')).toBeTruthy() - cleanup() - - const enterFailing = vi.fn().mockResolvedValueOnce('agent busy') - setup({ active: false, pending: false }, enterFailing) - fireEvent.click(offChip()) - expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy') - expect(offChip()).toBeTruthy() }) it('ignores in-flight fulfillment and rejection after unmount', () => { @@ -118,14 +92,14 @@ describe('PlanChip', () => { { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.click(onChip()) + fireEvent.click(chip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() }) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 4ab13662f2..23ce289533 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a014fa2333..58fc06888f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1367,6 +1367,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots From f9a40d555f2667b35b165a639bf2621a3524560b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:59:34 +0800 Subject: [PATCH 50/85] docs: record ui-conversation README pairing hash after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0f0529e337..96db5f7c1a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: e3625f40a1f6b0d4097cbef51818c250c88cd0e8 -README.zh.md: a1c10c085d30dd5241823492d666f3c0b58d7942 +README.md: fa4a19c6e97e423dc01bc037c7784b30c5d4c0e6 +README.zh.md: e09e1c85d7f8ac48b5d86ee9b81d3ba3f6da268e From d2521a7e6107e12090a69e5e8938ed5ee80560fe Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:09:59 -0700 Subject: [PATCH 51/85] test(web): refresh queue collapse access golden --- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..81394b8468 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -16,7 +16,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash From 6991650fce41ffe0c74fb0c1b45a8e0f7492c65e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 23:53:11 +0800 Subject: [PATCH 52/85] fix(config): map dsh-host-directory-picker-auto to workspace source web.cordis.yml references @deepseek-ai/dsh-host-directory-picker-auto but tsconfig.base.json had no paths entry for it, so the tsx source launch fell back to built lib/ and verify-cordis-config failed. Add the mapping alongside its -browse/-native siblings. --- tsconfig.base.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index f5f5c0a3ba..9bdaa91652 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -125,6 +125,8 @@ "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], "@deepseek-ai/dsh-host-directory-picker-native": ["./packages/host/directory-picker-native/src"], "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], + "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], + "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], From 2c8da83fc820ad41501c5533a4b1a6c292f2bbdc Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:27:49 -0700 Subject: [PATCH 53/85] test(web): refresh plan review access golden --- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index aca0bc31bb..70fc4c5d0e 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -42,7 +42,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash From f9f8148e794a9ba3ea59135cd5ca7f3586855269 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:08:54 +0800 Subject: [PATCH 54/85] refactor(credentials,llm): remove speculative mutation and route lifecycle --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 12 +- ...request-level-llm-config-credentials.zh.md | 12 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 10 +- ...l-boundaries-and-atomic-registration.zh.md | 10 +- ...redentials-and-static-llm-routes.i18n.yaml | 6 + ...-only-credentials-and-static-llm-routes.md | 35 ++ ...ly-credentials-and-static-llm-routes.zh.md | 35 ++ apps/cli/composition.md | 3 - apps/cli/config/base.cordis.yml | 27 +- apps/cli/package.json | 1 - apps/cli/src/tui.ts | 6 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 8 +- docs/config-catalog.md | 19 +- docs/cordis-catalog/events.md | 26 -- docs/cordis-catalog/services.md | 50 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 30 +- docs/core-data-structures/core.zh.md | 30 +- .../credentials.i18n.yaml | 4 +- docs/core-data-structures/credentials.md | 34 +- docs/core-data-structures/credentials.zh.md | 34 +- docs/event-producer-consumer.md | 1 - docs/module-graph.md | 9 +- .../headless-agent/tests/headless.snapshot.ts | 7 +- .../stream-json.expected.jsonl | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 41 +- packages/credentials/README.i18n.yaml | 4 +- packages/credentials/README.md | 10 +- packages/credentials/README.zh.md | 12 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 39 +- .../credentials-local/README.zh.md | 39 +- .../credentials-local/package.json | 3 - .../credentials-local/src/index.ts | 431 +----------------- .../credentials-local/src/invariant.ts | 3 +- .../credentials-local/tests/drain.spec.ts | 71 --- .../credentials-local/tests/local.spec.ts | 217 ++------- .../tests/review-fixes.spec.ts | 202 -------- .../credentials-local/tests/watcher.spec.ts | 223 --------- .../credentials-local/tsconfig.json | 3 - .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 26 +- packages/credentials/credentials/README.zh.md | 28 +- packages/credentials/credentials/src/index.ts | 128 +----- .../credentials/credentials/src/invariant.ts | 19 +- .../credentials/tests/credentials.spec.ts | 45 +- .../credentials/tests/invariant.spec.ts | 21 - .../credentials/credentials/tests/memory.ts | 39 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 12 +- packages/llm/llm-deepseek/README.zh.md | 12 +- packages/llm/llm-deepseek/src/index.ts | 37 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 5 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 26 +- .../tests/loader-composition.spec.ts | 24 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 12 +- packages/llm/llm-pi-ai/README.zh.md | 12 +- packages/llm/llm-pi-ai/src/config.ts | 20 +- packages/llm/llm-pi-ai/src/index.ts | 57 +-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 163 ++----- .../tests/loader-composition.spec.ts | 54 +-- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/index.ts | 114 +---- packages/llm/llm/tests/service.spec.ts | 29 +- packages/settings/settings-local/package.json | 2 - packages/settings/settings-local/src/index.ts | 80 +++- .../settings/settings-local/src/invariant.ts | 2 +- .../settings/settings-local/tsconfig.json | 3 - packages/settings/settings/src/index.ts | 45 +- .../settings/settings/tests/settings.spec.ts | 75 --- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 1 - packages/util/README.zh.md | 1 - packages/util/atomic-write/README.i18n.yaml | 6 - packages/util/atomic-write/README.md | 45 -- packages/util/atomic-write/README.zh.md | 45 -- packages/util/atomic-write/package.json | 37 -- packages/util/atomic-write/src/index.ts | 157 ------- packages/util/atomic-write/src/invariant.ts | 30 -- .../atomic-write/tests/atomic-write.spec.ts | 48 -- .../util/atomic-write/tests/invariant.spec.ts | 18 - packages/util/atomic-write/tsconfig.json | 15 - pnpm-lock.yaml | 21 - scripts/gen-cordis-catalog.ts | 3 - scripts/type-equiv.manifest.json | 15 - .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 100 files changed, 559 insertions(+), 2752 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md delete mode 100644 packages/credentials/credentials-local/tests/drain.spec.ts delete mode 100644 packages/credentials/credentials-local/tests/review-fixes.spec.ts delete mode 100644 packages/credentials/credentials-local/tests/watcher.spec.ts delete mode 100644 packages/util/atomic-write/README.i18n.yaml delete mode 100644 packages/util/atomic-write/README.md delete mode 100644 packages/util/atomic-write/README.zh.md delete mode 100644 packages/util/atomic-write/package.json delete mode 100644 packages/util/atomic-write/src/index.ts delete mode 100644 packages/util/atomic-write/src/invariant.ts delete mode 100644 packages/util/atomic-write/tests/atomic-write.spec.ts delete mode 100644 packages/util/atomic-write/tests/invariant.spec.ts delete mode 100644 packages/util/atomic-write/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c7861321a0..4ec3f9de2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a +2026-07-29-request-level-llm-config-credentials.md: ec00b52bdbe8f00d334618f3e5347974a3928e67 +2026-07-29-request-level-llm-config-credentials.zh.md: 29835b9fb320e6b31cb49a632ff0e56d88fb3f44 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index f12a2496a7..ec00b52bdb 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) -> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins) and the `packages/credentials/` capability family. The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes speculative credential mutation, the atomic-write extraction, and settings-driven route lifecycle; this note owns the surviving request-resolution rationale. ## Problem @@ -12,18 +12,18 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Decision -**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the operation, while an in-flight stream keeps the facts it started with. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. Provider routes and their retry policies are composition-fixed instead of triggering registration swaps. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps, and a bad live snapshot keeps the last good request facts while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override request facts for those routes but cannot add or remove them. ## Alternatives considered - **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. - **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. -- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; retry policy therefore stays fixed with the composition-owned route. ## Consequences -Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)). +Booting without a key remains valid: the first request fails with the named reference, and an externally supplied environment or dotenv value reaches the next request without restart. The demos mount `settings-local` and the read-only `credentials-local` provider by default and inline no `!!js` key plumbing. The credential-management RPC/UI and registration mutation are absent until a current consumer justifies their contracts. Settings-layer arrays still replace wholesale, and pi-ai provider routes remain composition decisions. The [credential-boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md) owns the surviving storage and request-generation safety decisions. diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 99fd90013a..29835b9fb3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-29-request-level-llm-config-credentials.md) | 中文 -> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)与 `packages/credentials/` 能力族。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了超前加入的凭据变更、atomic-write 抽取与 settings 驱动的路由生命周期;本 note 负责保留至今的请求解析理由。 ## 问题 @@ -12,18 +12,18 @@ Status: implemented ## 决策 -**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 +**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实在操作期间读取,进行中的流则保持其起始事实。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。提供方路由及其重试策略由组合固定,不触发注册替换。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam,最后仅在未挂载 seam 时读取点名的原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由。 ## 曾考虑的替代方案 - **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 - **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 -- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 +- **注册表级的实时重试策略**:让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;因此,重试策略与组合所拥有的路由一同保持固定。 ## 后果 -上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。 +无密钥启动仍然有效:第一次请求会失败并点名该引用,而从外部提供的环境变量或 dotenv 值无需重启即可作用于下一次请求。demo 默认挂载 `settings-local` 与只读的 `credentials-local` 提供方,不内联任何 `!!js` 密钥接线。在当前消费方为其契约提供依据之前,凭据管理 RPC/UI 与注册变更均不存在。settings 层的数组仍整体替换,pi-ai 提供方路由也仍由组合决定。[凭据边界 note](2026-07-30-credential-boundaries-and-atomic-registration.md)负责保留至今的存储与请求世代安全决策。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 98f2b0cb0d..f7fde2b4e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa +2026-07-30-credential-boundaries-and-atomic-registration.md: 09beda90d4789f951f663a3f9df794d74db2c11e +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 84f3826ba8a8b89fd5dd616164ac573151b8203c diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 6fe5f554ac..09beda90d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md) -> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`. +> Scope: storage and request-boundary corrections to the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md). The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes credential writes, the shared atomic writer, and mutable registration; this note owns the surviving secret boundary and whole-request generation rules. ## Problem @@ -20,17 +20,17 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. -**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. +**Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched. -**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. +**Credential resolution has no publication lifecycle.** The seam is read-only and consumers resolve for each operation, so external changes need no cache invalidation or event. `installSettingsSection` only switches the consumer's source thunk between the live scope and composition entry; committed values are read directly through that thunk. ## Alternatives considered - **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. - **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see. - **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. -- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. +- **A mutable adapter-registration handle** — it can make replacement ownership structural, but current provider routes are known at composition and no shipped consumer needs mutation. Static registration removes the lifecycle instead. ## Consequences -`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). +The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer, and `DeepSeekConnectionOptions` carries credential facts with its endpoint so one rejected settings generation cannot contribute only a key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 3eb3b02206..84f3826ba8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 -> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。 +> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 ## 问题 @@ -24,17 +24,17 @@ Status: implemented **一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 -**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 +**提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber,并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。 -**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 +**凭据解析没有发布生命周期。**该 seam 只读,消费方每个操作都会解析,因此外部变更无需缓存失效或事件。`installSettingsSection` 只在存活 scope 与组合配置项之间切换消费方的来源 thunk;已提交值直接通过该 thunk 读取。 ## 曾考虑的替代方案 - **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。 - **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 - **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 -- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 +- **可变的适配器注册句柄**:它可以使替换所有权成为结构关系,但当前提供方路由在组合时便已知,并且没有已交付的消费方需要变更能力。静态注册直接移除了这项生命周期。 ## 后果 -`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 +本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器;`DeepSeekConnectionOptions` 将凭据事实与端点一同携带,因此一代被拒绝的 settings 不可能只贡献密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml new file mode 100644 index 0000000000..0e69e9aba4 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.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/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md +2026-07-31-read-only-credentials-and-static-llm-routes.md: 2ebbea28c2dadeb9482c483247249ed4054749e4 +2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: ad21d93bf73ca0063eb97da8e610aa2814ee1726 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md new file mode 100644 index 0000000000..2ebbea28c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md @@ -0,0 +1,35 @@ +# Agent Note: read-only credentials and static LLM routes + +Status: implemented + +English | [中文](2026-07-31-read-only-credentials-and-static-llm-routes.zh.md) + +## Problem + +The first request-level LLM configuration design shipped future configuration-UI capabilities before that UI existed. The credential seam exposed description, mutation, and change events; its local provider therefore needed a watcher, cache, operation queue, dotenv editor, writer lock, and a new shared atomic-write package. No production caller used those operations. Mutable adapter registrations and a dormant pi-ai mount similarly existed so settings could create routes even though provider ownership is a composition decision. + +That speculative closure accounted for much of the feature's runtime and test growth, widened public contracts, and introduced lifecycle and concurrency failure modes unrelated to the two current consumers, which only need to resolve a named key for a request. + +## Decision + +`ctx.credentials` exposes only branded `CredentialRef` construction and `resolve(ref): Promise`. `credentials-local` reads the named process environment value, then parses its dotenv file on demand. It owns no mutation, description, event, watcher, cache, editor, or writer lifecycle; externally changing either source is visible to the next resolution. + +LLM provider routes and their retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change request-level facts for those existing routes but cannot create, remove, or retune registrations. The shared CLI composition therefore does not mount an empty pi-ai adapter. + +The optional-settings helper only switches a consumer's source thunk between its composition entry and a live settings scope. Consumers read committed values through that thunk, so the helper needs no update watcher, derived-state callback, or teardown-state mirror. `settings-local` keeps its write protocol private instead of publishing a utility for a second writer that no longer exists. + +## Alternatives considered + +**Keep the credential writer for the planned web surface.** A future UI may need mutation and redacted description, but its exact RPC, ownership, and security contract is not shipped. Reintroducing the smallest closure with that consumer is cheaper than maintaining a generic write lifecycle meanwhile. + +**Cache the dotenv file and watch for invalidation.** Per-resolution file I/O is small beside a model request and makes external rotation current without watcher readiness, debounce, missed-event, and disposal semantics. + +**Keep mutable route registration as a generic registry feature.** Current adapters know their provider routes at composition. A mutable public handle creates a lifecycle state solely for a deferred settings-driven route feature. + +**Keep a shared atomic-write package for settings alone.** One consumer does not justify a public package, peer dependency, invariant companion, and independent test surface; the settings provider owns its private write protocol. + +## Consequences + +Credential rotation remains restart-free when an operator or external secret manager changes the environment or dotenv document, but the harness offers no credential-management API or UI contract. pi-ai deployments explicitly compose at least one provider route. The remaining public seams match current production calls, and the removed watcher/editor/registration machinery no longer contributes concurrency or teardown states. + +Focused seam, provider, dynamic-settings, Loader-composition, and missing-credential snapshot tests pin the smaller closure. The earlier [request-level configuration](../architecture/2026-07-29-request-level-llm-config-credentials.md) and [credential-boundary](../architecture/2026-07-30-credential-boundaries-and-atomic-registration.md) notes retain the motivation and surviving request/security decisions while deferring to this note for the removed mutation and route-lifecycle contracts. diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md new file mode 100644 index 0000000000..ad21d93bf7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md @@ -0,0 +1,35 @@ +# Agent Note(agent 决策记录):只读凭据与静态 LLM(大语言模型)路由 + +Status: implemented + +[English](2026-07-31-read-only-credentials-and-static-llm-routes.md) | 中文 + +## 问题 + +第一版请求级 LLM 配置设计在相应 UI 尚不存在时,便交付了面向未来配置 UI 的能力。凭据 seam 暴露描述、修改和变更事件,因此本地提供方需要 watcher、缓存、操作队列、dotenv 编辑器、写入锁,以及一个新的共享 atomic-write 包(package)。没有生产调用方使用这些操作。可变适配器注册与休眠的 pi-ai 挂载同样是为了让 settings 创建路由而存在,尽管提供方所有权属于组合决策。 + +这些超前加入的能力占据了该功能大部分运行时与测试增量,扩大了公开契约,还引入了与两个当前消费方无关的生命周期和并发失败模式;这两个消费方只需要为一次请求解析点名的密钥。 + +## 决策 + +`ctx.credentials` 只暴露品牌化 `CredentialRef` 的构造,以及 `resolve(ref): Promise`。`credentials-local` 先读取点名的进程环境值,再按需解析其 dotenv 文件。它不拥有修改、描述、事件、watcher、缓存、编辑器或写入器生命周期;从外部更改任一来源,都会在下一次解析时生效。 + +LLM 提供方路由及其重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 可以更改这些现有路由的请求级事实,但不能创建、移除或重新调整注册。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 + +可选 settings 辅助工具只在组合配置项与存活 settings scope 之间切换消费方的来源 thunk。消费方经该 thunk 读取已提交值,因此辅助工具不需要更新 watcher、派生状态回调或拆卸状态镜像。`settings-local` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。 + +## 曾考虑的替代方案 + +**为计划中的 web surface 保留凭据写入器。**未来的 UI 可能需要变更与脱敏后的描述,但其确切 RPC、所有权和安全契约尚未交付。届时随消费方重新引入满足需求的最小能力闭包,比在此期间维护通用写入生命周期成本更低。 + +**缓存 dotenv 文件并通过 watcher 触发失效。**相比一次模型请求,每次解析执行的文件 I/O 很小;直接读取可以让外部轮换始终生效,而无需引入 watcher 就绪、防抖、事件漏失与资源释放语义。 + +**保留可变路由注册,将其作为通用注册表功能。**当前适配器在组合时便已知自身的提供方路由。可变公开句柄只为一项延后的 settings 驱动路由功能创建了生命周期状态。 + +**只为 settings 保留共享 atomic-write 包。**一个消费方不足以证明公开包、对等依赖(peer dependency)、不变量配套实现与独立测试面的必要性;settings 提供方拥有自身的私有写入协议。 + +## 后果 + +当操作者或外部机密管理器更改环境或 dotenv 文档时,凭据轮换仍然无需重启,但 harness 不提供凭据管理 API 或 UI 契约。pi-ai 部署必须显式组合至少一条提供方路由。余下公开 seam 与当前生产调用相符,被移除的 watcher、编辑器与注册机制也不再引入并发或拆卸状态。 + +针对 seam、提供方、动态 settings、Loader 组合与凭据缺失快照的聚焦测试固定了这个更小的能力闭包。先前的[请求级配置](../architecture/2026-07-29-request-level-llm-config-credentials.md)与[凭据边界](../architecture/2026-07-30-credential-boundaries-and-atomic-registration.md) note 保留其动机以及仍然适用的请求与安全决策;对于已移除的变更和路由生命周期契约,则以本 note 为准。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 2e71c6c07b..547d4bfa83 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -32,8 +32,6 @@ flowchart LR cfg --> plugin_tui_settings plugin_tui_credentials["credentials
                      @deepseek-ai/dsh-credentials-local"] cfg --> plugin_tui_credentials - plugin_tui_llm_pi_ai["llm-pi-ai
                      @deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
                      @deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_tui_session_persistence_jsonl plugin_tui_session_query_sqlite["session-query-sqlite
                      @deepseek-ai/dsh-session-query-sqlite"] @@ -120,7 +118,6 @@ flowchart LR | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-local` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | -| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e885ce6ef1..85ef71b305 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -56,29 +56,20 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' -# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a -# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries -# below without a restart, and is what the web Models page writes. +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): the +# `llm-deepseek:` section overrides request-level adapter facts below without +# a restart. Provider routes and retry policy remain composition-owned. - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` -# (owner-only file, hot-reloaded). Adapters resolve their key references -# through it at each request, so no key is inlined in this file — and nothing -# hoists that document into the process environment, which would make every -# stored key read as an unrotatable ambient override. +# Credential reader: the live process environment over `$DSH_HOME/.env`. +# Adapters resolve their key references on demand, so external rotations reach +# the next request without a watcher, cache, or mutation API. Nothing hoists +# that document into the process environment, where it would become an ambient +# override. - id: credentials name: '@deepseek-ai/dsh-credentials-local' -# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra -# models in the picker) until a `llm-pi-ai:` settings section supplies provider -# profiles — then those routes register live, keys resolving per request -# through their apiKeyEnv references, and drop again when the section empties. -# Which adapters exist is composition; which providers run is the user's -# settings document. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: @@ -237,7 +228,7 @@ # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per # request from the `llm-deepseek:` settings section over this entry, with the -# key coming from the credential store below. Thinking defaults are a surface +# key coming from the credential reader below. Thinking defaults are a surface # choice. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/apps/cli/package.json b/apps/cli/package.json index 94cf6da9c2..1485a5af12 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -66,7 +66,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 3469a737c1..515f31f470 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -123,9 +123,9 @@ export async function runTui( } installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the - // whole environment: $DSH_HOME/.env is credentials-local's writable store, - // and hoisting it would make every stored key read as a read-only ambient - // override on the next run — unrotatable from the TUI or the web page. + // whole environment: credentials-local reads $DSH_HOME/.env on demand, and + // hoisting it would make every stored key an ambient override whose later + // file rotations cannot take effect. // The environment is settled, so switching the workspace here cannot alter // its precedence. The cwd IS the workspace seam: the shipped config // resolves the session cwd and the HMR watch root from it, so one chdir moves diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 714a838124..945f6200cf 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -359,10 +359,10 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // SURFACE OVERLAY inserted, not one the base declares — proving a later // patch list reaches a row an earlier one inserted. The single `!!js` // expression prefers the PERSONAL variable, so the welcome can only render - // the project value while the harness home's .env — the credential store - // of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting - // it would make every stored key read as a read-only launch override on - // the next run and hand it to every subprocess the agent starts. + // the project value while the harness home's .env — the document read by + // `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting it + // would make every stored key a launch override and hand it to every + // subprocess the agent starts. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 159fc308fd..0ae1cf6ea1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -410,20 +410,16 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ## `@deepseek-ai/dsh-credentials-local` ```ts config-catalog -/** Plugin config: file location and hot-reload behavior. */ +/** Plugin config: the optional credential document location. */ export interface Config { /** Credentials document path; defaults to `.env` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string - /** Watch the document and hot-publish external edits; defaults to true. */ - watch?: boolean - /** Watcher write-settle window in milliseconds; defaults to 100. */ - debounceMs?: number } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:17`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -663,7 +659,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -672,12 +668,8 @@ Requires: `llm` ```ts config-catalog /** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** - * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is - * the dormant settings-driven posture: the adapter mounts with no routes - * and registers them the moment a settings section supplies profiles. - */ - providers?: Record + /** Non-empty pi-ai provider routes, keyed by provider and fixed by composition. */ + providers: Record } /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ @@ -2343,7 +2335,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) -- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 249d26b416..9d48809dd0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -439,32 +439,6 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) -## `credentials/*` - -### `credentials/updated` — emit - -Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. - -```ts cordis-catalog -/** - * Committed change to a provider-managed credential source: a `set`, an - * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. Listener - * failures are contained and logged — a sync throw and an async rejection - * alike — without changing the committed operation's outcome, except - * `INVARIANT`-coded failures, which rethrow after every listener ran; - * that rethrow reaches the emitter only from synchronous listeners, so - * invariant checks on this event must not be async functions. - * @param ref - the reference whose stored value changed. - * @mode emit - */ -'credentials/updated'(ref: CredentialRef): void -``` - -Types: [CredentialRef](../core-data-structures/credentials.md) - -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) - ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3149d6b572..b72caab0fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -490,49 +490,21 @@ Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/comp ## `ctx.credentials` — `Credentials` (abstract seam) -Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +Abstract read-only credential service. ```ts cordis-catalog /** - * Resolve one reference to its current value. Resolution is per call: - * consumers re-resolve at each operation and must not cache across - * operations — that per-operation read is what makes a changed credential - * reach the next operation without a restart. + * Resolve one reference to its current non-empty value. Consumers call once + * per operation and do not cache across operations. * @param ref - the reference to resolve. - * @returns the value and its source, or `undefined` while unconfigured. + * @returns the current value, or `undefined` while unconfigured. */ -abstract resolve(ref: CredentialRef): Promise - -/** - * Describe one reference for configuration surfaces without exposing the - * value. - * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. - */ -abstract describe(ref: CredentialRef): Promise - -/** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). - * @param ref - the reference to store. - * @param value - the non-empty secret value. - */ -abstract set(ref: CredentialRef, value: string): Promise - -/** - * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. - * @param ref - the reference to remove. - */ -abstract unset(ref: CredentialRef): Promise +abstract resolve(ref: CredentialRef): Promise ``` -Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) +Types: [CredentialRef](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:34`](../../packages/credentials/credentials/src/index.ts) ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) @@ -790,9 +762,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. + * @returns the disposer that unregisters all routes. */ -registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle +registerAdapter(providers: string[], adapter: LlmAdapter): () => void /** * Describe provider routes with a registered adapter. @@ -864,9 +836,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:215`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e998f1f4a4..9e32dcc915 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 docs/core-data-structures/core.md -core.md: 09b437a8483134230d4b941c20940c5655bc53f0 -core.zh.md: 2025707db397203dbaec52c59172f83367f2033e +core.md: fa313fee4f29c79ae0713b156ed248cca7285e21 +core.zh.md: be604b24474f2308ddb31d50ae88b73e6aaa4084 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 09b437a848..fa313fee4f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,7 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | -| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | +| [credentials.md](credentials.md) | the read-only credential seam: `CredentialRef` references and per-operation value resolution | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | @@ -183,33 +183,7 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. -Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs. - -```ts type-equiv -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} -``` +Registering an adapter binds one non-empty provider-route set to the calling fiber and returns its disposer. Routes are composition-owned rather than mutable user settings. ```ts type-equiv /** Display metadata for one registered provider route. */ diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 2025707db3..be604b2447 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,7 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | -| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | +| [credentials.md](credentials.md) | 只读凭据 seam:`CredentialRef` 引用与按操作解析值 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | @@ -189,33 +189,7 @@ interface MessageSourceMap { 提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 -注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。 - -```ts type-equiv -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} -``` +注册适配器会把一组非空提供方路由绑定到调用方 fiber,并返回相应的释放器。路由归组合所有,不属于可变的用户设置。 ```ts type-equiv /** Display metadata for one registered provider route. */ diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..7e494a8966 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.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 docs/core-data-structures/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: 90e39a013f6b557ca5d4f07facebda390b908305 +credentials.zh.md: 92986f484bcc271538d02169038bbe71ebd5e4a6 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..90e39a013f 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -2,7 +2,7 @@ English | [中文](credentials.zh.md) -The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. +The [dsh-credentials](../../packages/credentials/credentials) seam lets configuration name secrets by reference rather than carry their values. Providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) resolve the current non-empty value, and consumers resolve once per operation so an external rotation reaches the next operation without a restart. Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -17,34 +17,4 @@ type CredentialRef = Branded<'CredentialRef'> ## Resolution -`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. - -```ts type-equiv -/** One resolved credential value and the source layer that supplied it. */ -interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} -``` - -## Description - -`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. - -```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} -``` - -## Change commits - -`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. +`ctx.credentials.resolve(ref)` returns the provider's current non-empty secret string, or `undefined` while unconfigured. Consumers do not cache across operations. The seam deliberately exposes no mutation, source-description, enumeration, or change-event contract; the generated [service catalog](../cordis-catalog/services.md) owns the method signature. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..92986f484b 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -2,7 +2,7 @@ [English](credentials.md) | 中文 -[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 +[dsh-credentials](../../packages/credentials/credentials) seam 允许配置以引用点名机密,而非携带机密值。[dsh-credentials-local](../../packages/credentials/credentials-local) 这类提供方解析当前非空值,消费方每个操作解析一次,因此从外部轮换的值无需重启即可作用于下一次操作。 Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -17,34 +17,4 @@ type CredentialRef = Branded<'CredentialRef'> ## 解析 -`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 - -```ts type-equiv -/** One resolved credential value and the source layer that supplied it. */ -interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} -``` - -## 描述 - -`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 - -```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} -``` - -## 变更提交 - -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 +`ctx.credentials.resolve(ref)` 返回提供方当前的非空机密字符串,未配置时返回 `undefined`。消费方不跨操作缓存。该 seam 刻意不暴露变更、来源描述、枚举或变更事件契约;方法签名由生成的[服务目录](../cordis-catalog/services.md)负责。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 950544938a..f0dbda5137 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,7 +26,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e0d102d237..22e361d608 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -8,7 +8,6 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid flowchart TD subgraph group_util["packages/util"] - pkg_atomic_write["atomic-write"] pkg_brand["brand"] pkg_native_command["native-command"] pkg_paths["paths"] @@ -266,7 +265,6 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end - pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants @@ -383,7 +381,6 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants - pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths @@ -402,7 +399,6 @@ flowchart TD pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings @@ -1016,7 +1012,6 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | -| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | @@ -1070,12 +1065,12 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index a09e0281cc..07de430205 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -191,12 +191,11 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and offers a literal key last. + // The guidance names the external credential sources and the literal + // configuration escape hatch without promising an unshipped writer UI. expect(result.stderr).toBe( 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' - + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' - + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' + + ' provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal' + ' "apiKey" in the llm-deepseek settings section\n', ) const normalized = normalizeHeadlessStream(result.stdout, runCwd) 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 index c48a42f62e..77e7ff6462 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -4,5 +4,5 @@ {"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\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","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\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} +{"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\"; provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal \"apiKey\" in the llm-deepseek settings section","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\"; provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1c9473b245..31b1793f13 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -266,23 +266,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'credentials', - summary: 'Abstract credential service.', + summary: 'Abstract read-only credential service.', methods: [ { - signature: 'abstract resolve(ref: CredentialRef): Promise', - jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', - }, - { - signature: 'abstract describe(ref: CredentialRef): Promise', - jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', - }, - { - signature: 'abstract set(ref: CredentialRef, value: string): Promise', - jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', - }, - { - signature: 'abstract unset(ref: CredentialRef): Promise', - jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current non-empty value. Consumers call once\n * per operation and do not cache across operations.\n * @param ref - the reference to resolve.\n * @returns the current value, or `undefined` while unconfigured.\n */', }, ], }, @@ -405,8 +393,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', methods: [ { - signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle', - jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */', + signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', + jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all routes.\n */', }, { signature: 'listProviders(): LlmProviderInfo[]', @@ -1293,13 +1281,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, - { - name: 'credentials/updated', - mode: 'emit', - signature: '\'credentials/updated\'(ref: CredentialRef): void', - jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', - summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', - }, { name: 'domain/changed', mode: 'emit', @@ -1521,10 +1502,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'AdapterRegistrationHandle', - declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', - }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', @@ -1753,10 +1730,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, - { - name: 'CredentialInfo', - declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', - }, { name: 'CredentialRef', declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', @@ -2189,10 +2162,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, - { - name: 'ResolvedCredential', - declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', - }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index e8b35ba48e..84df37baa8 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/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/credentials/README.md -README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 -README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b +README.md: e7dc38db9be95bdacabc19538c06d87723e9c922 +README.zh.md: 74bf5d9b53e6861271b4810474c6eddd4388da51 diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 1d450cbeef..e7dc38db9b 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -The credential capability seam, as three-package shape dictates (interface / implementation / consumers): +The credential capability keeps secret values behind provider-owned references: | Package | Role | |---|---| -| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | -| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references and per-operation `resolve` | +| [`credentials-local/`](credentials-local/README.md) | Read-only provider: the live process environment layered over an on-demand `$DSH_HOME/.env` read | -Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. +Configuration can carry a reference such as `apiKeyEnv: DEEPSEEK_API_KEY` instead of the secret itself. LLM adapters resolve that reference for each model request, so an externally rotated environment or dotenv value reaches the next request without restarting the harness. -The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. +The seam can also support keyring-, helper-command-, and KMS-backed providers when a shipped consumer needs one. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index 843230c3ce..74bf5d9b53 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): +凭据能力把机密值留在提供方拥有的引用背后: -| 包 | 角色 | +| 包(package) | 角色 | |---|---| -| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | -| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用与按操作 `resolve` | +| [`credentials-local/`](credentials-local/README.md) | 只读提供方:活跃进程环境叠加按需读取的 `$DSH_HOME/.env` | -配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 +配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY` 这样的引用,而非机密本身。LLM(大语言模型)适配器每次模型请求都会解析该引用,因此从外部轮换的环境变量或 dotenv 值无需重启 harness 即可作用于下一次请求。 -seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 +已交付的消费方需要时,该 seam 也可以支持由 keyring、辅助命令或 KMS 支撑的提供方。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 89a8576683..d06f03448a 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 -README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d +README.md: dcfaea036595c68eb07b104d9075b67d17dad2ac +README.zh.md: 635d41a8e156b71d749419e90e408c184e9adbbc diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 126140b107..dcfaea0365 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -2,14 +2,14 @@ English | [中文](README.zh.md) -File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. +Read-only [credentials](../credentials/README.md) provider with two externally managed sources: -| Layer | Source id | Writable | Wins | -|---|---|---|---| -| Live process environment | `env` | no | always | -| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | +| Layer | Wins | +|---|---| +| Live process environment | Always, when the named value is non-empty | +| `$DSH_HOME/.env` document | Otherwise | -The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. +The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, or a prepared shell) is operator intent for that process. The provider never writes either source. ## Config @@ -17,28 +17,20 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, |---|---|---| | `path` | `/.env` | Credentials document location. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | -| `watch` | `true` | Hot-publish external edits. | -| `debounceMs` | `100` | Watcher write-settle window. | -## The document +## Resolution -dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. +Each `resolve(ref)` reads `process.env[ref]` first. If it is absent or empty, the provider reads the dotenv document and parses it with `dotenv`; a missing file, missing key, or empty value resolves to `undefined`, while any other file error rejects the operation. Nothing is watched or cached, so an external edit is visible to the next resolution without a provider lifecycle or mutation API. -Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. - -## Hot reload - -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +The provider accepts dotenv's parsing semantics, including last-assignment precedence. It does not create the document or control its permissions; the operator or external credential-management surface owns both. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. - -That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. +The harness does not expose the resolved document path to the model or hoist the file into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). This is discretion, not isolation: tools run as the same OS user and can read any file that user's permissions allow. A deployment that must keep provider keys away from its own agent needs a provider backed by a store those tool processes cannot read. ## Model Experience -Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: resolved values authorize their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect @@ -46,9 +38,6 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. -- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. -- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred. -- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. -- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. -- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. +- **Mutation is external** — edit the dotenv document, launching environment, or upstream secret store; this provider intentionally has no write API. +- **Every file fallback performs I/O** — the implementation favors a small always-current read path over a watcher, cache, and invalidation lifecycle. +- **A same-UID process can read the document** — file permissions do not isolate a secret from model-invoked tools running as the same user. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index c22575115a..635d41a8e1 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 +只读[凭据](../credentials/README.md)提供方,包含两个由外部管理的来源: -| 层 | 来源 id | 可写 | 优先 | -|---|---|---|---| -| 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| 层 | 优先级 | +|---|---| +| 活跃进程环境 | 点名的值非空时始终优先 | +| `$DSH_HOME/.env` 文档 | 其余情况 | -环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密或预先设置好环境的 shell)代表操作者对该进程的意图。提供方不会写入任何一个来源。 ## 配置 @@ -17,28 +17,20 @@ |---|---|---| | `path` | `/.env` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | -| `watch` | `true` | 热发布外部编辑。 | -| `debounceMs` | `100` | watcher 写入稳定窗口。 | -## 文档本身 +## 解析 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +每次调用 `resolve(ref)` 时,提供方先读取 `process.env[ref]`。若该值不存在或为空,提供方再读取 dotenv 文档并以 `dotenv` 解析;文件不存在、键不存在或值为空时返回 `undefined`,其他文件错误则使该操作失败。实现不使用 watcher 或缓存,因此外部编辑无需经过提供方生命周期或变更 API,即可在下一次解析时生效。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 - -## 热重载 - -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +提供方接受 dotenv 的解析语义,包括最后一次赋值优先。它既不创建文档,也不控制其权限;两者均归操作者或外部凭据管理接口所有。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 - -这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +harness 不会向模型暴露解析后的文档路径,也不会把文件载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。这是审慎,不是隔离:工具以同一 OS 用户身份运行,可以读取该用户权限允许的任何文件。必须让提供方密钥远离自身 agent(智能体)的部署,需要采用由这些工具进程无法读取的存储支撑的提供方。 ## Model Experience -经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 +经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect @@ -46,9 +38,6 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 -- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 -- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 -- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 -- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 +- **修改由外部完成**:请编辑 dotenv 文档、启动环境或上游机密存储;该提供方刻意不提供写入 API。 +- **每次回退到文件都会执行 I/O**:实现选择小而始终读取当前值的路径,不引入 watcher、缓存和失效生命周期。 +- **同一 OS 用户的进程可以读取该文档**:文件权限无法将机密与以同一用户身份运行的模型调用工具隔离。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..a59c408154 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -27,19 +27,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "chokidar": "^4.0.3", "dotenv": "^17.2.0", "schemastery": "^3.18.0" }, "devDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c62d4411fa..5ab141428e 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,463 +1,72 @@ /** - * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.env` document. The environment is authoritative and read-only - * (a launch-time override must win, and must be visibly read-only rather than - * silently shadow writes); the file is the provider-managed writable source: - * every write re-reads the document under a cross-process writer lock before - * rewriting only its own line — preserving every other byte, physical line - * endings and quoted multi-line values included — external edits hot-publish - * through the seam, and each reload replaces the snapshot wholesale so a - * deleted entry never lingers in memory. + * Read-only credential provider layering the live process environment over a + * `$DSH_HOME/.env` document read on demand. * @module @deepseek-ai/dsh-credentials-local */ -import { Context, Service } from 'cordis' +import { Context } from 'cordis' import z from 'schemastery' -import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { parse } from 'dotenv' -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import { Credentials } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -/** Plugin config: file location and hot-reload behavior. */ +/** Plugin config: the optional credential document location. */ export interface Config { /** Credentials document path; defaults to `.env` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string - /** Watch the document and hot-publish external edits; defaults to true. */ - watch?: boolean - /** Watcher write-settle window in milliseconds; defaults to 100. */ - debounceMs?: number } -/** Fully resolved provider parameters; defaulting happens here, never inline. */ +/** Fully resolved provider parameters. */ interface ResolvedSpec { filename: string - watch: boolean - debounceMs: number } /** - * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/.env`. + * Resolve the runtime spec from plugin config. * @param config - raw plugin config. - * @returns the resolved file location and watch behavior. + * @returns the absolute credential document path. */ export function resolveSpec(config: Config): ResolvedSpec { - return { - filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), - watch: config.watch ?? true, - debounceMs: config.debounceMs ?? 100, - } + return { filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')) } } -/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +/** Whether a filesystem error means absence; every non-ENOENT failure surfaces. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Values that survive a dotenv round-trip without quoting. */ -const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ - -/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ -function hasControlCharacters(value: string): boolean { - for (const char of value) { - if (char.charCodeAt(0) < 0x20) return true - } - return false -} - -/** - * Render one `KEY=value` line in the narrowest style dotenv reads back - * verbatim: bare, then single quotes (fully literal), then double quotes - * (safe only without backslashes, which double-quote reading expands). - * A value no style can represent fails loud instead of corrupting silently. - */ -function renderLine(ref: CredentialRef, value: string): string { - if (BARE_VALUE.test(value)) return `${ref}=${value}` - if (hasControlCharacters(value)) { - throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) - } - if (!value.includes('\'')) return `${ref}='${value}'` - if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` - throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) -} - -/** Split text into physical lines with their terminators attached. */ -function physicalLines(text: string): string[] { - return text.length === 0 ? [] : text.split(/(?<=\n)/) -} - -/** One physical line's content without its terminator. */ -function lineContent(line: string): string { - if (line.endsWith('\r\n')) return line.slice(0, -2) - if (line.endsWith('\n')) return line.slice(0, -1) - return line -} - -/** One physical line's terminator (empty on a final unterminated line). */ -function lineTerminator(line: string): string { - return line.slice(lineContent(line).length) -} - -/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ -const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ - -/** Quote characters dotenv reads across physical lines. */ -const MULTILINE_QUOTES = ['\'', '"', '`'] - -/** - * The quote character an assignment's value part opens without closing on its - * own line — the following physical lines are that value's continuation, not - * assignments — or `undefined` for a single-line value. - */ -function opensMultiline(valuePart: string): string | undefined { - const trimmed = valuePart.trimStart() - const quote = trimmed[0] - if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined - const rest = trimmed.slice(1) - const body = quote === '"' ? rest.replaceAll('\\"', '') : rest - return body.includes(quote) ? undefined : quote -} - -/** Whether a continuation line closes the given quote. */ -function closesQuote(content: string, quote: string): boolean { - const body = quote === '"' ? content.replaceAll('\\"', '') : content - return body.includes(quote) -} - -/** - * Replace, insert, or delete one reference's assignment while preserving - * every other byte: untouched lines keep their exact content and terminators - * (CRLF included), and the physical lines inside another key's quoted - * multi-line value are never mistaken for assignments. The first matching - * assignment is rewritten in place with its own line ending; later duplicates - * drop (dotenv reads the last one, so a surviving duplicate would override - * the edit); an insert appends in the document's dominant ending style. - */ -function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { - const lines = physicalLines(text ?? '') - const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' - const out: string[] = [] - let placed = false - let pendingQuote: string | undefined - for (const line of lines) { - const content = lineContent(line) - if (pendingQuote !== undefined) { - // Inside a quoted multi-line value: never an assignment, always kept. - if (closesQuote(content, pendingQuote)) pendingQuote = undefined - out.push(line) - continue - } - const match = ASSIGNMENT.exec(content) - if (match === null) { - out.push(line) - continue - } - const [, key, valuePart] = match - if (key !== ref) { - /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ - pendingQuote = opensMultiline(valuePart ?? '') - out.push(line) - continue - } - // The write path refuses multi-line targets before rendering, so the - // matched assignment is single-line and drops or rewrites wholesale. - if (rendered !== undefined && !placed) { - out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) - placed = true - } - } - if (rendered !== undefined && !placed) { - const last = out[out.length - 1] - if (last !== undefined && lineTerminator(last) === '') { - out[out.length - 1] = `${last}${dominant}` - } - out.push(`${rendered}${dominant}`) - } - return out.join('') -} - /** File-backed credentials provider (`$DSH_HOME/.env`). */ export class CredentialsLocal extends Credentials { - /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with - settings-local (prefer symmetry for parallel values); extracting the shared - shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), - watch: z.boolean().default(true), - debounceMs: z.number().min(0).default(100), }) private readonly spec: ResolvedSpec - /** - * Raw text of the last read or persisted document; `undefined` while the - * file is absent. Watcher events whose content equals this cache are no-ops, - * which is also the self-write suppression. - */ - private text: string | undefined - /** Parsed document snapshot; replaced wholesale on every reload. */ - private values = new Map() - /** - * Single exclusive operation chain: watcher reloads and line edits run one - * at a time in queue order (settled tail), so an edit can never render from - * text a concurrent reload is busy replacing. - */ - private operations: Promise = Promise.resolve() - /** Set at dispose: refuse new writes and let in-flight work no-op. */ - private closed = false - - /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ - private isClosed(): boolean { - return this.closed - } - /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) - // Programmatic construction may bypass Schemastery normalization; resolve - // the same defaults in one explicit step either way. this.spec = resolveSpec(config) } - async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { - yield async () => { - // Drain: refuse new operations, then settle the queued ones so disposal - // completes only once storage is quiescent. - this.closed = true - await this.operations - } - await this.loadInitial() - if (!this.spec.watch) return - /* jscpd:ignore-start -- same watcher discipline as settings-local by design: - the serialized-refresh and quiesce-on-dispose shape is the reviewed - lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: this.spec.debounceMs, - pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), - }, - }) - watcher.on('all', () => { - if (this.closed) return - this.queueRefresh() - }) - watcher.on('ready', () => { - // The initial load raced the watcher's own setup: a change written - // between that read and the watcher becoming active never fires an - // event. One reconcile at ready closes the gap. - if (this.closed) return - this.queueRefresh() - }) - watcher.on('error', (error) => { - this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) - this.ctx.logger.warn(error) - }) - yield async () => { - // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight operation so nothing publishes after disposal. - this.closed = true - await watcher.close() - await this.operations - } - /* jscpd:ignore-end */ - } + override async resolve(ref: CredentialRef): Promise { + const ambient = process.env[ref] + if (ambient !== undefined && ambient.length > 0) return ambient - override resolve(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) - const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) - return Promise.resolve(undefined) - } - - override describe(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { - return Promise.resolve({ configured: true, source: 'env', writable: false }) - } - const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) { - // A quoted multi-line value resolves fine but the line editor refuses to - // rewrite it, so writability must say what set() would actually do. - return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) - } - return Promise.resolve({ configured: false, writable: true }) - } - - override async set(ref: CredentialRef, value: string): Promise { - if (value.length === 0) { - throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) - } - await this.write(ref, value) - } - - override async unset(ref: CredentialRef): Promise { - await this.write(ref, undefined) - } - - /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same - reviewed contract as settings-local, deliberately mirrored (prefer symmetry - for parallel values); the two providers own different documents and - failure policies, so extracting the shape would couple their teardown - semantics across packages for a handful of lines. */ - /** Queue one exclusive document operation behind every earlier one. */ - private enqueue(operation: () => Promise): Promise { - const task = this.operations.then(operation) - this.operations = task.then(() => undefined, () => undefined) - return task - } - - /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */ - private queueRefresh(): void { - void this.enqueue(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the update fan-out can reject a - // refresh; keep the operation queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) - } - /* jscpd:ignore-end */ - - /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ - private async write(ref: CredentialRef, value: string | undefined): Promise { - const verb = value === undefined ? 'unset' : 'set' - if (this.isClosed()) { - throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) - } - this.assertUnshadowed(ref, verb) - return this.enqueue(async () => { - if (this.isClosed()) { - throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) - } - // Re-judged at run time: the environment may have changed while queued. - this.assertUnshadowed(ref, verb) - // The writer lock's exclusive create needs the parent to exist; 0700 - // because the harness home holds user-private data. - await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { - // Read-modify-write: fold in any on-disk state this process has not - // observed yet — an external edit still inside the watcher debounce - // window, a change the watcher missed, or another process's write — - // so the line edit below can never resurrect a stale document. - await this.reconcileFromDisk() - const existing = this.values.get(ref) - if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) - // 0600: a document holding secrets is never world-readable. - await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) - this.text = nextText - if (value === undefined) this.values.delete(ref) - else this.values.set(ref, value) - // After the commit: a broken observer must never make the durable - // write look failed (an INVARIANT failure still rethrows). - this.notifyUpdated(ref) - }, { - onStaleBreak: (lockPath) => { - this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath) - }, - }) - }) - } - - /** Reject a write the live environment would shadow into apparent no-effect. */ - private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { - throw new Error( - `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; change the launching environment instead', - ) - } - } - - /** Boot read: an absent file is an empty store; any other failure is loud. */ - private async loadInitial(): Promise { let text: string try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { - if (!isENOENT(error)) throw error - return + if (isENOENT(error)) return undefined + throw error } - this.text = text - this.values = new Map(Object.entries(parse(text))) - } - - /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and - reconcile policy: warn-and-keep on a reload, throw on a write, invariant - failures propagate. */ - /** - * Re-read the document after a watcher event. Unchanged content (including - * this provider's own writes) is a no-op; an unreadable document keeps the - * last good snapshot and warns — a live hot-reload must never take the - * process down. An invariant violation escaping the fan-out is not a reload - * failure and propagates to the queue's error surface. - */ - private async refresh(): Promise { - if (this.closed) return - try { - await this.reconcileFromDisk() - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error - this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - } - } - - /** - * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty store; an unreadable file - * throws, so each caller picks its policy — a reload warns and keeps the - * last good snapshot, a write fails loud. dotenv parsing is lenient by - * design and cannot fail. - */ - private async reconcileFromDisk(): Promise { - let text: string | undefined - try { - text = await readFile(this.spec.filename, 'utf8') - } catch (error) { - if (!isENOENT(error)) throw error - text = undefined - } - if (text === this.text || this.isClosed()) return - const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) - const changed = this.changedRefs(this.values, next) - this.text = text - this.values = next - for (const ref of changed) this.notifyUpdated(ref) - } - /* jscpd:ignore-end */ - - /** Seam-addressable entries whose effective (non-empty) value changed. */ - private changedRefs(prev: Map, next: Map): CredentialRef[] { - const changed: CredentialRef[] = [] - for (const key of new Set([...prev.keys(), ...next.keys()])) { - const before = prev.get(key) - const after = next.get(key) - const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined - const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined - if (effectiveBefore === effectiveAfter) continue - try { - changed.push(credentialRef(key)) - } catch (_unaddressableKey) { - // A key that is not a POSIX identifier is preserved file content the - // seam cannot address, so no observer could ever see it change. - } - } - return changed + const stored = parse(text)[ref] + return stored === undefined || stored.length === 0 ? undefined : stored } } diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts index 9ec75ed21d..024be1c4ea 100644 --- a/packages/credentials/credentials-local/src/invariant.ts +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -15,8 +15,7 @@ export const name = 'credentials-local-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the - * `credentials/updated` lifecycle contract; this provider's file/environment layering is + * No runtime invariant: this provider's file/environment layering is * asynchronous I/O pinned by its unit suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts deleted file mode 100644 index baefbd52c5..0000000000 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -// The atomic write is the gated asynchronous hold point inside a queued -// write; gating it makes the dispose-versus-queued-write race fully -// deterministic. The lock helper passes through so the gated operation still -// runs inside its real acquire/release cycle. -vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => { - const actual = await importOriginal() - let gate: Promise = Promise.resolve() - return { - ...actual, - writeFileAtomic: vi.fn(() => gate), - __setGate: (next: Promise) => { - gate = next - }, - } -}) - -async function setGate(next: Promise): Promise { - const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise) => void } - mocked.__setGate(next) -} - -const KEY = credentialRef('DSH_CRED_DRAIN_A') -const OTHER = credentialRef('DSH_CRED_DRAIN_B') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - await setGate(Promise.resolve()) - while (cleanups.length > 0) await cleanups.pop()!() -}) - -describe('write-drain teardown', () => { - it('lets the in-flight write land and fails the queued one after disposal', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) - await fiber - const service = ctx.credentials - - let release!: () => void - await setGate(new Promise((resolveGate) => { - release = resolveGate - })) - const first = service.set(KEY, 'one') - // Let the first task pass its liveness checks and park on the gate, so it - // is genuinely in-flight when disposal begins. - await new Promise(resolvePause => setTimeout(resolvePause, 5)) - // Attach the rejection handler up front: the queued write fails while the - // drain is still awaited, before any later `await expect` could run. - const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/) - const disposal = fiber.dispose() - // Give the drain disposer its first turn (set closed) before opening the gate. - await new Promise(resolvePause => setTimeout(resolvePause, 10)) - release() - await disposal - - await expect(first).resolves.toBeUndefined() - await secondRejects - expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' }) - expect(await service.resolve(OTHER)).toBeUndefined() - }) -}) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 4ebaed1a0c..f4f18b101c 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -1,15 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' const KEY = credentialRef('DSH_CRED_TEST') const OTHER = credentialRef('DSH_CRED_OTHER') - const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -23,222 +21,69 @@ async function tempDir(): Promise { return dir } -async function boot(config: ConstructorParameters[1]): Promise { +async function boot(path: string): Promise { const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { - await fiber.dispose() - }) - await fiber + await ctx.plugin(CredentialsLocal, { path }) + cleanups.push(async () => { await ctx.fiber.dispose() }) return ctx } -function updates(ctx: Context): CredentialRef[] { - const seen: CredentialRef[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - return seen -} - describe('resolveSpec', () => { - it('defaults to .env under the harness home with watching on', () => { - const spec = resolveSpec({ dshHome: '/custom/home' }) - expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + it('defaults to .env under the harness home', () => { + expect(resolveSpec({ dshHome: '/custom/home' })) + .toEqual({ filename: resolve('/custom/home/.env') }) }) it('lets an explicit path win over the home', () => { - const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) - expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + expect(resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored' })) + .toEqual({ filename: resolve('/etc/dsh/creds.env') }) }) }) -describe('layering and reads', () => { - it('treats an absent file as an empty writable store', async () => { +describe('read-only resolution', () => { + it('treats an absent file as unconfigured', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot(join(dir, '.env')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('serves file entries, including export-prefixed and quoted values', async () => { + it('parses export-prefixed, quoted, and multiline dotenv values', async () => { const dir = await tempDir() const path = join(dir, '.env') - await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) - expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="line one\nline two"\n') + const ctx = await boot(path) + expect(await ctx.credentials.resolve(KEY)).toBe('plain') + expect(await ctx.credentials.resolve(OTHER)).toBe('line one\nline two') }) - it('lets a non-empty process environment win read-only over the file', async () => { + it('reads the live environment first on every call', async () => { const dir = await tempDir() const path = join(dir, '.env') await writeFile(path, 'DSH_CRED_TEST=from-file\n') - const ctx = await boot({ path, watch: false }) + const ctx = await boot(path) vi.stubEnv('DSH_CRED_TEST', 'from-env') - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + expect(await ctx.credentials.resolve(KEY)).toBe('from-env') + vi.stubEnv('DSH_CRED_TEST', '') + expect(await ctx.credentials.resolve(KEY)).toBe('from-file') }) - it('treats empty values as absent in both layers', async () => { + it('re-reads the file on every call and treats empty values as absent', async () => { const dir = await tempDir() const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=first\n') + const ctx = await boot(path) + expect(await ctx.credentials.resolve(KEY)).toBe('first') + await writeFile(path, 'DSH_CRED_TEST=second\n') + expect(await ctx.credentials.resolve(KEY)).toBe('second') await writeFile(path, 'DSH_CRED_TEST=\n') - const ctx = await boot({ path, watch: false }) - vi.stubEnv('DSH_CRED_TEST', '') expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('fails boot loud when the document exists but cannot be read', async () => { + it('surfaces non-absence read failures at resolution time', async () => { const dir = await tempDir() const path = join(dir, 'occupied') await mkdir(path) - const ctx = new Context() - await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow() - }) -}) - -describe('line-editing writes', () => { - it('appends a missing key to a fresh 0600 document and emits the commit', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const seen = updates(ctx) - await ctx.credentials.set(KEY, 'sk-fresh') - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') - expect((await stat(path)).mode & 0o777).toBe(0o600) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) - expect(seen).toEqual([KEY]) - }) - - it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(KEY, 'new value!') - expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') - }) - - it('quotes hostile values so they round-trip through a fresh provider', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const singleQuoted = 'with "quote", back\\slash and space' - const doubleQuoted = "it's got an apostrophe" - await ctx.credentials.set(KEY, singleQuoted) - await ctx.credentials.set(OTHER, doubleQuoted) - const reread = await boot({ path, watch: false }) - expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) - expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) - }) - - it('fails loud on values no .env quoting style reads back verbatim', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) - await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - }) - - it('unsets only the owning line and keeps an absent unset silent', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') - const ctx = await boot({ path, watch: false }) - const seen = updates(ctx) - await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') - await ctx.credentials.unset(KEY) - expect(seen).toEqual([KEY]) - }) - - it('rejects empty values, shadowed writes, and multi-line entries', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') - const ctx = await boot({ path, watch: false }) - - await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) - - vi.stubEnv('DSH_CRED_TEST', 'shadowing') - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) - }) - - it('leaves an empty document after unsetting the only entry', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=only\n') - const ctx = await boot({ path, watch: false }) - await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('') - }) - - it('chains past a rejected write so one bad value cannot poison the queue', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - const good = ctx.credentials.set(OTHER, 'lands') - await bad - await good - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') - }) - - it('serializes concurrent writes so both land in the one document', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - await Promise.all([ - ctx.credentials.set(KEY, 'one'), - ctx.credentials.set(OTHER, 'two'), - ]) - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') - }) - - it('refuses writes after disposal', async () => { - const dir = await tempDir() - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) - await fiber - // Capture the handle first: disposal also removes the ctx.credentials service. - const service = ctx.credentials - await fiber.dispose() - await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/) - }) -}) - -describe('real hot reload', () => { - it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - // Watching starts on an existing document: creation racing watcher setup - // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST=boot\n') - const ctx = await boot({ path, debounceMs: 10 }) - const seen = updates(ctx) - - await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) - }) - - // Wholesale replacement: an entry deleted on disk never lingers in memory. - await writeFile(path, 'DSH_CRED_TEST=live\n') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() - }) - - const before = seen.length - await ctx.credentials.set(KEY, 'self-written') - await new Promise(resolvePause => setTimeout(resolvePause, 200)) - // Exactly the committed write's own event: the watcher echo of our own - // content is recognized by the text cache and publishes nothing extra. - expect(seen.length).toBe(before + 1) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' }) + const ctx = await boot(path) + await expect(ctx.credentials.resolve(KEY)).rejects.toThrow() }) }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts deleted file mode 100644 index 7583cf0813..0000000000 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Third-review behaviors: read-modify-write under the writer lock (external -// edits survive an API write), the contained credentials/updated fan-out (a -// broken observer never fails a committed write), and the physical-line -// editor's multi-line and CRLF discipline. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -const ALPHA = credentialRef('DSH_REVIEW_ALPHA') -const BETA = credentialRef('DSH_REVIEW_BETA') -const INNER = credentialRef('DSH_REVIEW_INNER') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('read-modify-write', () => { - it('folds an unobserved external edit into a write instead of overwriting it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { seen.push(ref) }) - await ctx.credentials.set(ALPHA, 'one') - // The external edit has landed on disk but no watcher reported it (watch - // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) - await ctx.credentials.set(ALPHA, 'two') - const text = await readFile(path, 'utf8') - expect(text).toContain(`${BETA}=external`) - expect(text).toContain(`${ALPHA}=two`) - // The fold published the unobserved entry before the write's own commit. - expect(seen).toEqual([ALPHA, BETA, ALPHA]) - expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) - }) - - it('keeps both refs when two providers write the same document concurrently', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const first = await boot({ path, watch: false }) - const second = await boot({ path, watch: false }) - await Promise.all([ - (async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(), - (async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(), - ]) - const third = await boot({ path, watch: false }) - expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' }) - expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' }) - }) - - it('breaks a stale writer lock with a warning and writes through', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - await writeFile(`${path}.lock`, 'crashed-holder\n') - const past = (Date.now() - 60_000) / 1000 - await utimes(`${path}.lock`, past, past) - await ctx.credentials.set(ALPHA, 'nine') - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`) - }) - - it('creates the credentials directory owner-only', async () => { - const dir = await tempDir() - const home = join(dir, 'home') - const ctx = await boot({ path: join(home, '.env'), watch: false }) - await ctx.credentials.set(ALPHA, 'one') - expect((await stat(home)).mode & 0o777).toBe(0o700) - }) -}) - -describe('contained update fan-out', () => { - it('does not fail a committed set when a listener throws, and later listeners still run', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - ctx.on('credentials/updated', () => { - throw new Error('observer boom') - }) - const second = vi.fn() - ctx.on('credentials/updated', second) - await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() - expect(second).toHaveBeenCalledWith(ALPHA) - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) - }) - - it('contains an async listener rejection', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - // An unknown-returning function keeps the typed surface legal while the - // runtime value is still the rejected promise the containment must handle. - const boom = (): unknown => Promise.reject(new Error('async observer boom')) - ctx.on('credentials/updated', boom) - await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - ctx.on('credentials/updated', () => { - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - const second = vi.fn() - ctx.on('credentials/updated', second) - await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) - // Harness-fatal by design — but the write itself committed first. - expect(second).toHaveBeenCalledWith(ALPHA) - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) - }) -}) - -describe('physical-line editor', () => { - it('never mistakes a quoted multi-line continuation for an assignment', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` - await writeFile(path, wrapped) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - // The wrapped value survives byte-for-byte; only ALPHA's line changed. - const afterAlpha = await readFile(path, 'utf8') - expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) - // Setting the inner-looking ref appends a real assignment; the - // continuation line inside the quoted value stays untouched. - await ctx.credentials.set(INNER, 'real') - const afterInner = await readFile(path, 'utf8') - expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) - expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) - }) - - it('preserves CRLF line endings on untouched and edited lines', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) - await ctx.credentials.set(INNER, 'new') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) - }) - - it('terminates a final unterminated line before appending', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(BETA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) - }) - - it('rewrites a final unterminated assignment in the dominant ending style', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) - }) - - it('tracks a single-quoted multi-line value through its continuation', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'x') - expect(await readFile(path, 'utf8')) - .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) - }) - - it('reports a multi-line entry as unwritable and refuses to edit it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}="line1\nline2"\n`) - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) - await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) - await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) - // Resolution still serves the multi-line value. - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) - }) -}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts deleted file mode 100644 index 6ff53252cf..0000000000 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -// chokidar is the nondeterministic OS boundary: faking it lets these tests -// drive the event pipeline (error events, races with unreadable files) -// deterministically. Real end-to-end watching stays covered by local.spec.ts. -vi.mock('chokidar', async () => { - const { EventEmitter } = await import('node:events') - class FakeWatcher extends EventEmitter { - close = vi.fn(() => Promise.resolve()) - } - const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] - return { - watch: vi.fn((path: string, options: unknown) => { - const watcher = new FakeWatcher() - instances.push({ path, options, watcher }) - return watcher - }), - __instances: instances, - } -}) - -interface FakeChokidar { - __instances: Array<{ - path: string - options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } - watcher: import('node:events').EventEmitter - }> -} - -async function fakeInstances(): Promise { - const chokidar = await import('chokidar') as unknown as FakeChokidar - return chokidar.__instances -} - -const KEY = credentialRef('DSH_CRED_PIPE') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() - ;(await fakeInstances()).length = 0 -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { - await fiber.dispose() - }) - await fiber - return ctx -} - -describe('watcher pipeline', () => { - it('clamps the write-settle poll interval for a zero debounce', async () => { - const dir = await tempDir() - await boot({ path: join(dir, '.env'), debounceMs: 0 }) - const [instance] = await fakeInstances() - expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) - }) - - it('survives a watcher error and keeps publishing later edits', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - const [instance] = await fakeInstances() - - instance!.watcher.emit('error', new Error('watch backend failure')) - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - - await writeFile(path, 'DSH_CRED_PIPE=arrived\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) - }) - }) - - it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=good\n') - const ctx = await boot({ path, debounceMs: 5 }) - - await chmod(path, 0o000) - cleanups.push(() => chmod(path, 0o600)) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'change', path) - // The warn-and-keep path is asynchronous; give the serialized refresh a turn. - await new Promise(resolve => setTimeout(resolve, 50)) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) - }) - - it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - let arm = true - ctx.on('credentials/updated', () => { - if (!arm) return - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - const [instance] = await fakeInstances() - - await writeFile(path, 'DSH_CRED_PIPE=first\n') - instance!.watcher.emit('all', 'change', path) - // The snapshot commits before the fan-out, so the value lands even though - // the listener threw out of the refresh. - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) - }) - - arm = false - await writeFile(path, 'DSH_CRED_PIPE=second\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) - }) - }) - - it('quiesces the refresh pipeline before dispose completes', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=initial\n') - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) - await fiber - let disposed = false - let postDisposeCommits = 0 - ctx.on('credentials/updated', () => { - if (disposed) postDisposeCommits += 1 - }) - - await writeFile(path, 'DSH_CRED_PIPE=changed\n') - const [instance] = await fakeInstances() - // Two queued refreshes: dispose interrupts one mid-flight and the other - // before it starts, so both closed guards must hold. - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('all', 'change', path) - await fiber.dispose() - disposed = true - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('ready') - await new Promise(resolve => setTimeout(resolve, 100)) - expect(postDisposeCommits).toBe(0) - }) - - it('empties the snapshot when the document is deleted and emits the removals', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=doomed\n') - const ctx = await boot({ path, debounceMs: 5 }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - - await rm(path) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'unlink', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - }) - expect(seen).toEqual([KEY]) - }) - - it('publishes only seam-addressable keys and preserves the rest untouched', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') - const ctx = await boot({ path, debounceMs: 5 }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - - await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) - }) - // The dash-named key is preserved file content the seam cannot address: - // its change publishes nothing and breaks nothing. - expect(seen).toEqual([KEY]) - }) - - it('treats an event for a still-absent file as a no-op', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'add', path) - await new Promise(resolve => setTimeout(resolve, 50)) - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - }) - - it('reconciles at watcher ready so a change during setup is not missed', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${KEY}=a\n`) - const ctx = await boot({ path, debounceMs: 5 }) - // Written after the initial load but before the watcher became active: - // no 'all' event will ever fire for it. - await writeFile(path, `${KEY}=written-before-ready\n`) - const [instance] = await fakeInstances() - instance!.watcher.emit('ready') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' }) - }) - }) -}) diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 3acfbdeffe..57e58c3830 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../util/atomic-write" - }, { "path": "../../util/paths" }, diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 10fe5f0ffe..40c2f2c67e 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/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/credentials/credentials/README.md -README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc -README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 +README.md: 1070f21c11a7ce87a7d901a235edbd3d0b0a8da1 +README.zh.md: 0787a6b93daeeb7a49b2230fba309e2bd7a508d9 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 1c18c47623..1070f21c11 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -2,13 +2,7 @@ English | [中文](README.zh.md) -Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: - -**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. - -**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. - -**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. +Abstract read-only credential seam (`ctx.credentials`). Configuration carries a branded reference such as `DEEPSEEK_API_KEY`; the provider owns the value, and the consumer resolves it only when starting an operation. ## Surface @@ -18,20 +12,15 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded -const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value -await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref -await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +const ref = credentialRef('DEEPSEEK_API_KEY') +const value = await ctx.credentials.resolve(ref) // string | undefined ``` -`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. - -The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. +`credentialRef()` accepts POSIX-style environment-variable names and brands them so references do not mix with unrelated cross-package strings. `resolve(ref)` returns the current non-empty value or `undefined`. Consumers resolve once per operation and do not cache across operations; mutation, source metadata, enumeration, and change events stay out of the seam until a current consumer requires them. ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. Other providers may resolve the same reference vocabulary from a keyring, helper command, or KMS without changing consumers. ## Model Experience @@ -43,6 +32,5 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. -- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. -- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. +- **No mutation, description, or enumeration** — the seam only resolves references already named by consumer configuration; a credential-management UI requires its own justified contract. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace remains sufficient for current consumers. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 751fb7c1e8..0787a6b93d 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -2,13 +2,7 @@ [English](README.md) | 中文 -抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: - -**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 - -**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 - -**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 +抽象的只读凭据 seam(`ctx.credentials`)。配置携带 `DEEPSEEK_API_KEY` 这样的品牌化引用;值归提供方所有,消费方只在操作开始时解析它。 ## 接口面 @@ -18,24 +12,19 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded -const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value -await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref -await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +const ref = credentialRef('DEEPSEEK_API_KEY') +const value = await ctx.credentials.resolve(ref) // string | undefined ``` -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 - -`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 +`credentialRef()` 接受 POSIX 风格的环境变量名并为其添加品牌类型,使引用不会与其他跨包(package)字符串混用。`resolve(ref)` 返回当前非空值,未配置时返回 `undefined`。消费方每个操作解析一次,不跨操作缓存;在当前消费方需要之前,seam 不引入修改、来源元数据、枚举或变更事件。 ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。其他提供方可以从 keyring、辅助命令或 KMS 解析相同的引用词汇,而无需改动消费方。 ## Model Experience -经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 +经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect @@ -43,6 +32,5 @@ await ctx.credentials.unset(ref) // no-op when absent; s ## Known Limitations and Deferred Work -- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 -- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 -- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 +- **不提供修改、描述或枚举**:seam 只解析消费方配置已经点名的引用;凭据管理 UI 需要自身有明确依据的契约。 +- **引用限定为环境变量形状**:单一扁平的 POSIX 标识符命名空间足以满足当前消费方。 diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b640b42881..51cdd12279 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -1,10 +1,6 @@ /** - * Credential seam (`ctx.credentials`). Settings and composition files carry - * *references* to secrets — environment-variable names — while providers own - * the actual values and their storage. Consumers resolve a reference once per - * operation, so a changed credential reaches the next operation without any - * plugin restart, and configuration surfaces describe a reference without - * ever seeing its value. + * Read-only credential seam (`ctx.credentials`). Configuration carries + * branded references to secrets; providers resolve their current values. * @module @deepseek-ai/dsh-credentials */ @@ -28,135 +24,25 @@ export function credentialRef(value: string): CredentialRef { return value as CredentialRef } -/** One resolved credential value and the source layer that supplied it. */ -export interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} - -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -export interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} - declare module 'cordis' { interface Context { credentials: Credentials } - - interface Events { - /** - * Committed change to a provider-managed credential source: a `set`, an - * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. Listener - * failures are contained and logged — a sync throw and an async rejection - * alike — without changing the committed operation's outcome, except - * `INVARIANT`-coded failures, which rethrow after every listener ran; - * that rethrow reaches the emitter only from synchronous listeners, so - * invariant checks on this event must not be async functions. - * @param ref - the reference whose stored value changed. - * @mode emit - */ - 'credentials/updated'(ref: CredentialRef): void - } } -/** - * Abstract credential service. Providers implement the four operations over - * their source layers; one seam-wide rule binds them all: an empty stored - * value is absent everywhere — `resolve` skips it, `describe` reports it - * unconfigured — so a blank never masquerades as a configured secret. - */ +/** Abstract read-only credential service. */ export abstract class Credentials extends Service { constructor(ctx: Context) { super(ctx, 'credentials') } /** - * Resolve one reference to its current value. Resolution is per call: - * consumers re-resolve at each operation and must not cache across - * operations — that per-operation read is what makes a changed credential - * reach the next operation without a restart. + * Resolve one reference to its current non-empty value. Consumers call once + * per operation and do not cache across operations. * @param ref - the reference to resolve. - * @returns the value and its source, or `undefined` while unconfigured. + * @returns the current value, or `undefined` while unconfigured. */ - abstract resolve(ref: CredentialRef): Promise - - /** - * Describe one reference for configuration surfaces without exposing the - * value. - * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. - */ - abstract describe(ref: CredentialRef): Promise - - /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). - * @param ref - the reference to store. - * @param value - the non-empty secret value. - */ - abstract set(ref: CredentialRef, value: string): Promise - - /** - * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. - * @param ref - the reference to remove. - */ - abstract unset(ref: CredentialRef): Promise - - /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit - fan-out: the contained-dispatch shape is the reviewed listener-lifecycle - contract, and extracting it would couple the two seams' event semantics. */ - /** - * Fan `credentials/updated` out with contained listener failures: every - * listener runs, and a sync throw or async rejection is logged without - * changing the committed operation's outcome — except `INVARIANT`-coded - * failures, which rethrow after every listener ran (the rethrow reaches the - * caller only from synchronous listeners, so invariant checks on this event - * must not be async functions). Providers call this only after the write or - * reload actually committed, so a broken observer can never make a durable - * change look failed. - * @param ref - the reference whose stored value changed. - */ - protected notifyUpdated(ref: CredentialRef): void { - let invariantFailure: unknown - const args = ['credentials/updated', ref] - for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { - try { - const returned = listener(ref) - if (returned != null && typeof (returned as PromiseLike).then === 'function') { - void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { - this.warnListenerFailure(ref, error) - }) - } - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { - invariantFailure ??= error - continue - } - this.warnListenerFailure(ref, error) - } - } - if (invariantFailure !== undefined) throw invariantFailure as Error - } - /* jscpd:ignore-end */ - - /** Contained-listener diagnostic shared by the sync and async failure paths. */ - private warnListenerFailure(ref: CredentialRef, error: unknown): void { - this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) - this.ctx.logger.warn(error) - } + abstract resolve(ref: CredentialRef): Promise } export default Credentials diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts index 23c2dda45b..ae8de8976c 100644 --- a/packages/credentials/credentials/src/invariant.ts +++ b/packages/credentials/credentials/src/invariant.ts @@ -4,7 +4,7 @@ */ import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' @@ -14,20 +14,11 @@ export const name = 'credentials-invariant' export const inject = ['invariants'] /** - * Install the commit-event lifecycle contract: `credentials/updated` names a - * committed provider-source change, so it can only fire while a credentials - * service is live — an emission after disposal means a provider leaked work - * past its teardown quiescence. The value relation itself (`describe` - * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by - * each provider's own suite. + * No runtime invariant: this read-only seam exposes no event sequence or + * mutable data relation; provider resolution crosses an asynchronous I/O + * boundary and stays pinned by each provider's own suite. */ -const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { - ctx.on('credentials/updated', (ref) => { - if (ctx.get('credentials') === undefined) { - fail(`credentials/updated for "${ref}" emitted without a live credentials service`) - } - }) -} +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts index 9b4cf7b1e8..0b32b59fbe 100644 --- a/packages/credentials/credentials/tests/credentials.spec.ts +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -1,17 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { credentialRef } from '../src/index.ts' -import type { CredentialRef } from '../src/index.ts' import { MemoryCredentials } from './memory.ts' const REF = credentialRef('DEEPSEEK_API_KEY') -async function boot(seed: Record = {}): Promise { - const ctx = new Context() - await ctx.plugin(MemoryCredentials, seed) - return ctx -} - describe('credentialRef', () => { it('brands POSIX shell identifiers', () => { expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') @@ -26,39 +19,17 @@ describe('credentialRef', () => { }) }) -describe('the credentials seam through the memory provider', () => { - it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { - const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) - expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) - expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) +describe('the credentials seam', () => { + it('mounts as ctx.credentials and resolves non-empty values', async () => { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toBe('sk-seeded') }) - it('treats an empty stored value as absent everywhere', async () => { - const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + it('treats an empty provider value as absent', async () => { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: '' }) expect(await ctx.credentials.resolve(REF)).toBeUndefined() - expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) - }) - - it('stores through set, removes through unset, and emits the committed change', async () => { - const ctx = await boot() - const events: CredentialRef[] = [] - ctx.on('credentials/updated', ref => void events.push(ref)) - - await ctx.credentials.set(REF, 'sk-live') - expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) - await ctx.credentials.unset(REF) - expect(await ctx.credentials.resolve(REF)).toBeUndefined() - expect(events).toEqual([REF, REF]) - }) - - it('rejects an empty set and keeps an absent unset silent', async () => { - const ctx = await boot() - const events: CredentialRef[] = [] - ctx.on('credentials/updated', ref => void events.push(ref)) - - await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) - await ctx.credentials.unset(REF) - expect(events).toEqual([]) }) it('removes the service with its fiber', async () => { diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts index dccde4843f..544b26c707 100644 --- a/packages/credentials/credentials/tests/invariant.spec.ts +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -1,30 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import InvariantService from '@deepseek-ai/dsh-invariants' -import { credentialRef } from '../src/index.ts' import * as CredentialsInvariant from '../src/invariant.ts' -import { MemoryCredentials } from './memory.ts' - -const REF = credentialRef('DEEPSEEK_API_KEY') describe('credentials invariant companion', () => { - it('accepts a committed change emitted by a live service', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - await ctx.plugin(CredentialsInvariant) - await ctx.plugin(MemoryCredentials) - - await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() - }) - - it('fails an update event emitted without a live service', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - await ctx.plugin(CredentialsInvariant) - - expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) - }) - it('reserves the package name against duplicate registration', async () => { const ctx = new Context() await ctx.plugin(InvariantService) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index dc1ed77a06..615bae351e 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -1,11 +1,8 @@ import type { Context } from 'cordis' import { Credentials } from '../src/index.ts' -import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' -/** - * In-memory credentials provider for interface and consumer tests: one - * always-writable `memory` source seeded from plugin config. - */ +/** In-memory read-only credentials provider for seam tests. */ export class MemoryCredentials extends Credentials { private readonly store = new Map() @@ -14,36 +11,8 @@ export class MemoryCredentials extends Credentials { for (const [key, value] of Object.entries(seed)) this.store.set(key, value) } - override resolve(ref: CredentialRef): Promise { + override resolve(ref: CredentialRef): Promise { const value = this.store.get(ref) - return Promise.resolve(value === undefined || value.length === 0 - ? undefined - : { value, source: 'memory' }) - } - - override describe(ref: CredentialRef): Promise { - const value = this.store.get(ref) - const configured = value !== undefined && value.length > 0 - return Promise.resolve({ - configured, - ...configured ? { source: 'memory' } : {}, - writable: true, - }) - } - - override set(ref: CredentialRef, value: string): Promise { - if (value.length === 0) { - return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) - } - this.store.set(ref, value) - this.ctx.emit('credentials/updated', ref) - return Promise.resolve() - } - - override unset(ref: CredentialRef): Promise { - if (this.store.delete(ref)) { - this.ctx.emit('credentials/updated', ref) - } - return Promise.resolve() + return Promise.resolve(value === undefined || value.length === 0 ? undefined : value) } } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e02f994fef..402bc1e164 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce -README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e +README.md: 8532dab4731e25b4af777217ad7c5ffad521d924 +README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ab44b61e30..8532dab473 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,12 +47,12 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: +Request facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts: -- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good request facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart. -The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. +`ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration. ## App attribution @@ -72,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, and composition-fixed retry policy), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -107,7 +107,7 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4ecaf361fd..922fb50ae0 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,12 +47,12 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: +请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值: -- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用的请求事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.credentials`**:API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。 -唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 +`ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。 ## 应用归因 @@ -72,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照,以及由组合固定的重试策略),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -107,7 +107,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6623351774..0801f36747 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -6,8 +6,7 @@ * key through the optional credential seam (`ctx.credentials`), so a changed * base URL, catalog, or key reaches the very next request without restarting * anything, while an in-flight stream keeps the facts it started with. The - * one registration-captured fact — the retry policy — re-registers the route - * in place when it changes. + * registration-captured facts stay composition-fixed. * @module @deepseek-ai/dsh-llm-deepseek */ @@ -16,7 +15,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -167,12 +166,13 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { } export function apply(ctx: Context, config: Config): void { + const compositionOptions = resolveAdapterOptions(config) let current: () => Config = () => config - let lastRaw: Config | undefined - let lastGood: ResolvedDeepSeekOptions | undefined + let lastRaw: Config = config + let lastGood = compositionOptions const options = (): ResolvedDeepSeekOptions => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw) return lastGood try { const next = resolveAdapterOptions(raw) lastRaw = raw @@ -182,14 +182,12 @@ export function apply(ctx: Context, config: Config): void { // Static composition resolves before anything registers, so this branch // only sees a live settings snapshot failing a beyond-schema bound: // keep serving the last good facts and say so once per bad snapshot. - if (lastGood === undefined) throw error lastRaw = raw ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') ctx.logger.error(error) return lastGood } } - options() const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { // Every credential fact comes from the caller's snapshot, so a rejected @@ -199,7 +197,7 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) - if (hit !== undefined) return hit.value + if (hit !== undefined) return hit } else { // Without the seam, keep the historical ambient fallback so a plain // cordis.yml composition works from the environment alone. @@ -207,33 +205,18 @@ export function apply(ctx: Context, config: Config): void { if (ambient !== undefined && ambient.length > 0) return ambient } throw new LlmError( - `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` - + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` - + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', + `llm-deepseek: no API key for provider route "${PROVIDER}"; provide ${ref} through the credential` + + ' provider or launching environment, or set a literal "apiKey" in the llm-deepseek settings section', 'MISSING_CREDENTIAL', ) } const adapter = new DeepSeekAdapter({ options, 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 disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) - let registeredPolicy = options().retryPolicy - const ensureRegistrationFacts = (): void => { - const policy = options().retryPolicy - if (deepEqualJson(policy, registeredPolicy)) return - // The registry captures the retry policy at registration, so it is the one - // fact per-request resolution cannot refresh: swap the registration in one - // synchronous section (same adapter instance, no NO_ADAPTER window). - disposeRoute() - disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) - registeredPolicy = policy - } + ctx.llm.registerAdapter([PROVIDER], adapter) installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index e59af1185d..a1c050414b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -69,7 +69,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmDeepSeek, {}) const result = await assemble(ctx, { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index aec9229e25..811b9da1c6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -817,10 +817,9 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and mentions a literal key last. + // The guidance names real external sources and the literal escape hatch. await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) + .rejects.toThrow(/provide DEEPSEEK_API_KEY through the credential provider.*"apiKey"/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 3cd430ec14..9a89734c60 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -4,7 +4,6 @@ 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' @@ -13,7 +12,6 @@ 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') const cleanups: Array<() => Promise> = [] @@ -36,9 +34,8 @@ interface Harness { /** * Real dynamic composition: llm + settings-local + credentials-local + - * llm-deepseek over one temp harness home. `watch: false` keeps every change - * flowing through the in-process write path, which is deterministic; external - * file watching is the providers' own covered concern. + * llm-deepseek over one temp harness home. Settings updates use their owning + * write path; credentials are edited externally and read on demand. */ async function boot(dir: string, config: object): Promise { const ctx = new Context() @@ -48,7 +45,7 @@ async function boot(dir: string, config: object): Promise { await ctx.plugin(LlmService) const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmDeepSeek, config) return { ctx, settingsFiber } } @@ -70,7 +67,7 @@ describe('request-level dynamic configuration', () => { expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') await ctx.settings.update(NS, { baseURL: serverB.url }) - await ctx.credentials.set(KEY_REF, 'second-key') + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=second-key\n') await prompt(ctx) // No restart, no re-registration: the next request resolved both facts. @@ -97,7 +94,7 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: server.url }) await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - await ctx.credentials.set(KEY_REF, 'sk-arrived') + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=sk-arrived\n') await prompt(ctx) expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) @@ -113,12 +110,19 @@ describe('request-level dynamic configuration', () => { ]) }) - it('re-registers the route in place when the captured retry policy changes', async () => { + it('keeps the registration retry policy composition-fixed', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) await ctx.settings.update(NS, { - retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + retryPolicy: { mode: 'normal', maxRetries: 0 }, }) expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ mode: 'always', diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 402f94441d..8395d007af 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -17,7 +17,6 @@ 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' @@ -26,7 +25,6 @@ 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 @@ -69,7 +67,6 @@ async function loadComposition( " name: '@deepseek-ai/dsh-credentials-local'", ' config:', ` path: ${JSON.stringify(envPath)}`, - ' debounceMs: 10', ] : [], '- id: llm-deepseek', @@ -117,22 +114,19 @@ describe('llm-deepseek real dynamic composition', () => { 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. + // External edits, exactly as a user 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('keeps a stored key writable and rotatable across a real restart', async () => { + it('reads a stored key and an external rotation across a real restart', async () => { // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. vi.stubEnv('DEEPSEEK_API_KEY', '') @@ -140,23 +134,15 @@ describe('llm-deepseek real dynamic composition', () => { const second = await mockServer([{ kind: 'sse', events: textEvents }]) const boot = await loadComposition({ withDynamic: true, baseURL: first.url }) const home = root! - await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui') - expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) - .toEqual({ configured: true, source: 'file', writable: true }) + await writeFile(boot.envPath, 'DEEPSEEK_API_KEY=stored-directly\n') await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + expect(first.headers[0]?.authorization).toBe('Bearer stored-directly') await boot.ctx.fiber.dispose() context = undefined // Restart over the same harness home. const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home }) - const credentials = restarted.ctx.get('credentials')! - // The stored key is still the provider's own writable file entry — not a - // read-only launch override, which is what hoisting it would have made it. - expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' }) - expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true }) - // Rotation still works after the restart, and the next request uses it. - await credentials.set(KEY_REF, 'rotated-after-restart') + await writeFile(restarted.envPath, 'DEEPSEEK_API_KEY=rotated-after-restart\n') await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 25e825eead..9af6c5b499 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 0099c9acd39cd2d471936505726d68423f351c76 -README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f +README.md: e8b7adf122946fc22f231fafb521866cbacdc652 +README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 0099c9acd3..e8b7adf122 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,13 +35,13 @@ Configure credentials and deployment-specific transport settings per provider, k X-Deployment: production ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Composition must provide at least one route. Registration with `ctx.llm` is all-or-nothing: a collision with any route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Dynamic configuration (settings + credentials) -The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. +The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -111,8 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. -- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes. +- **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 7cb4f5fcbc..5bbf034267 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,13 +35,13 @@ X-Deployment: production ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。组合必须提供至少一条路由。向 `ctx.llm` 注册要么全部成功,要么全部不生效:如果与另一适配器已拥有的任何路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## 动态配置(settings + credentials) -适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 +适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定;settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;entry 配置本身会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 @@ -111,8 +111,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 -- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 053d6d56e6..a298873b28 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,7 +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. + * user-settings layer merge per provider. * * @module dsh-llm-pi-ai/config */ @@ -60,12 +60,8 @@ export interface ResolvedPiAiProviderProfile extends Omit + /** Non-empty pi-ai provider routes, keyed by provider and fixed by composition. */ + providers: Record } const thinkingBudgets = z.object({ @@ -92,24 +88,24 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.dict(profile).default({}), + providers: z.dict(profile).required(), }) /** * Validate profiles against the installed pi-ai catalog and return a detached * route-keyed map suitable for per-request reads. This is the one explicit - * resolve step, so an omitted dict resolves to the empty (dormant) route set - * here rather than through a hidden fallback. + * resolve step; a composition must name at least one route. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ export function resolveProfiles( - providers: Readonly> | undefined, + 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 ?? {}) + 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 resolved = new Map() for (const [provider, source] of entries) { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4c610cae21..46a4bf01f1 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -3,10 +3,9 @@ * 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. + * optional credential seam, so a changed key, endpoint, or request knob + * reaches the next request without a restart. Provider routes and retry + * policies stay composition-fixed. * * ```yaml * - id: llm @@ -30,7 +29,6 @@ import type { Context } from 'cordis' import { LlmError } from '@deepseek-ai/dsh-llm' -import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' @@ -59,14 +57,19 @@ function registrationFacts(profiles: ReadonlyMap Config = () => config - let lastRaw: Config | undefined - let lastGood: ReadonlyMap | undefined + let lastRaw: Config = config + let lastGood: ReadonlyMap = compositionProfiles const profiles = (): ReadonlyMap => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw) return lastGood try { const next = resolveProfiles(raw.providers) + if (!deepEqualJson(registrationFacts(next), compositionFacts)) { + throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed') + } lastRaw = raw lastGood = next return next @@ -74,14 +77,12 @@ export function apply(ctx: Context, config: Config): void { // 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 ( provider: string, @@ -97,55 +98,25 @@ export function apply(ctx: Context, config: Config): void { if (ref === undefined) return undefined const credentials = ctx.get('credentials') const hit = credentials !== undefined - ? (await credentials.resolve(ref))?.value + ? await credentials.resolve(ref) // Without the seam, read exactly the named variable so a plain // cordis.yml composition works from the environment alone. : process.env[ref] if (hit !== undefined && hit.length > 0) return hit throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` - + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` + + ` set — provide ${ref} through the credential provider or launching environment,` + ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery', 'MISSING_CREDENTIAL', ) } 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. A bare - // mount (zero routes) is the dormant posture: nothing registers until a - // settings section supplies profiles, and routes drop when it empties. - let registration: AdapterRegistrationHandle | undefined - let registeredFacts: unknown - 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, so a change to either must re-register. The swap is - // atomic (same adapter instance, validated before anything moves): a - // conflicting route leaves the previous routes serving requests, and - // `registeredFacts` only advances once the registry actually holds the - // new set — so returning to a working configuration always re-applies. - const routes = [...profiles().keys()] - if (registration === undefined) { - // Dormant bare mount: nothing is registered until a section supplies - // profiles, and an empty section keeps it that way. - if (routes.length === 0) { - registeredFacts = facts - return - } - registration = ctx.llm.registerAdapter(routes, adapter) - } else { - registration.replace(routes) - } - registeredFacts = facts - } - ensureRegistrationFacts() + ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter) installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..8edfaceb1a 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -401,9 +401,7 @@ describe('provider profile lifecycle', () => { }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { - // Empty and omitted dicts are the dormant zero-route posture, not errors. - expect(resolveProfiles({}).size).toBe(0) - expect(resolveProfiles(undefined).size).toBe(0) + expect(() => resolveProfiles({})).toThrow(/at least one profile/) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) // The pre-release array shape and its per-profile provider field fail diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 598d2aa2a9..a9735e1dcf 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -3,8 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' +import LlmService from '@deepseek-ai/dsh-llm' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' @@ -13,15 +12,6 @@ import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-pi-ai') - -/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */ -class StubAdapter extends LlmAdapter { - - override async * stream(): AsyncIterable { - throw new Error('stub adapter must never stream') - } -} - const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -36,154 +26,75 @@ async function home(): Promise { return dir } -/** Real dynamic composition mirroring the deepseek twin's harness. */ +/** 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() - }) + 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(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmPiAi, config) return ctx } describe('request-level dynamic profiles', () => { - it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { - vi.stubEnv('PI_DYNAMIC_KEY', '') - const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') - const server = await mockServer([{ events: textEvents }]) - // The exact product posture: `- id: llm-pi-ai` with no config at all. - const ctx = await boot(dir, {}) - - expect(ctx.llm.listProviders()).toEqual([]) - await ctx.settings.update(NS, { - providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, - }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) - await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) - - 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 pk-from-settings') - - // Emptying the user layer returns the adapter to its dormant state. - await ctx.settings.replace(NS, {}) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - 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 () => { + it('uses the next endpoint and credential while keeping the route fixed', 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 serverA = await mockServer([{ events: textEvents }]) + const serverB = await mockServer([{ events: textEvents }]) const ctx = await boot(dir, { - providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: serverA.url } }, }) await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + expect(serverA.headers[0]?.authorization).toBe('Bearer pk-one') - await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await ctx.settings.update(NS, { providers: { deepseek: { baseURL: serverB.url } } }) + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-two\n') await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer pk-two') + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) }) - it('re-registers routes in place when a captured retry policy changes', async () => { + it('rejects settings-born routes and keeps the composition profile serving', async () => { const dir = await home() - const ctx = await boot(dir, { providers: { openai: {} } }) + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } }, + }) await ctx.settings.update(NS, { + providers: { anthropic: { apiKey: 'other' } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/responses']) + }) + + it('keeps the registration retry policy composition-fixed', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: { - retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, }, }, }) + + await ctx.settings.update(NS, { + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: 0 } } }, + }) 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']) - }) - - it('keeps serving its routes when a settings-born route collides with another adapter', async () => { - const dir = await home() - const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) - // Another adapter owns `anthropic`; the registry must refuse to hand it over. - ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) - - await ctx.settings.update(NS, { - providers: { - openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, - anthropic: { apiKey: 'other' }, - }, - }) - - // The conflicting swap was refused whole: the previous route set still - // owns openai (an eager dispose would have dropped it), and anthropic - // still belongs to its original adapter. - expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) - const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) - expect(result.finish.kind).toBe('error') - expect(server.paths).toEqual(['/v1/responses']) - - // Reverting to the working configuration re-applies, even though its - // facts equal the ones the registry already holds. - await ctx.settings.replace(NS, {}) - expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) - await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) - expect(server.paths).toEqual(['/v1/responses', '/v1/responses']) - }) - - it('ignores a settings document that merely reorders its provider keys', async () => { - const dir = await home() - const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } }) - const before = ctx.llm.listProviders().map(provider => provider.id) - - // Same routes, different YAML key order: nothing about the registration - // changed, so no swap should happen at all. - await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before) }) }) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 460e78b7c2..0da44cc13e 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -1,11 +1,6 @@ /** - * Real-composition guard for the dormant pi-ai posture: LlmService, - * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a - * test-only cordis.yml through the actual Loader + Include path, an external - * edit of settings.yaml registers the route live, and the next request - * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot - * catch Loader export-shape failures, which is why the twin adapter has the - * same guard. + * Real-composition guard for a configured pi-ai route through Loader + Include. + * Settings may change request facts, while the route stays composition-owned. */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -18,6 +13,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' 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' @@ -25,6 +21,7 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' let root: string | undefined let context: Context | undefined +const NS = settingsNamespace('llm-pi-ai') afterEach(async () => { await context?.fiber.dispose() @@ -35,12 +32,12 @@ afterEach(async () => { vi.unstubAllEnvs() }) -/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */ -async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> { +async function loadComposition(baseURL: string): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') + const envPath = join(root, '.env') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + await writeFile(envPath, 'PI_COMPOSITION_KEY=key-from-store\n') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -54,10 +51,14 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(join(root, '.env'))}`, - ' debounceMs: 10', + ` path: ${JSON.stringify(envPath)}`, '- id: llm-pi-ai', " name: '@deepseek-ai/dsh-llm-pi-ai'", + ' config:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${baseURL}`, '', ].join('\n')) @@ -84,33 +85,34 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } config: { path: pathToFileURL(configPath).href }, }) await ctx.loader.await() - return { ctx, settingsPath } + return { ctx, settingsPath, envPath } } -describe('llm-pi-ai real dormant composition', () => { - it('boots with zero routes and registers one the moment settings supply a profile', async () => { +describe('llm-pi-ai real composition', () => { + it('keeps its route while external settings and credential edits reach the next request', async () => { vi.stubEnv('PI_COMPOSITION_KEY', '') - const server = await mockServer([{ events: textEvents }]) - const { ctx, settingsPath } = await loadComposition() + const serverA = await mockServer([{ events: textEvents }]) + const serverB = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition(serverA.url) - // The shipped posture: the adapter exists, no route does. - expect(ctx.llm.listProviders()).toEqual([]) + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer key-from-store') - // Exactly what the web Models page leaves on disk. await writeFile(settingsPath, [ 'llm-pi-ai:', ' providers:', ' deepseek:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ` baseURL: ${server.url}`, + ` baseURL: ${serverB.url}`, '', ].join('\n')) + await writeFile(envPath, 'PI_COMPOSITION_KEY=rotated-key\n') await vi.waitFor(() => { - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + expect((ctx.get('settings')!.get(NS) as { providers?: { deepseek?: { baseURL?: string } } }) + .providers?.deepseek?.baseURL).toBe(serverB.url) }, { timeout: 5000 }) - 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 key-from-store') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') }) }) diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 8a0ecf3434..eef06331da 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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/llm/llm-retry/README.md -README.md: 7a86652a794e70c4dfd00ab7427730387e3ec949 -README.zh.md: c66c04806597c11b5c64dcb01443bb84f489e0f5 +README.md: 233ccb0b744f7ec52379f823286c1c0638d91ee2 +README.zh.md: 86260785128f7089be7bcbae08b7e858427a68ae diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 7a86652a79..233ccb0b74 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -8,7 +8,7 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a later registration with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index c66c048065..8626078512 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -8,7 +8,7 @@ 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 -等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 +等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,后续注册若采用不同的限制、code 成员关系或退避,就会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index d7740dcebf..48b6febe4e 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 5b0c1b2dcafeefaad25f1714e4a1783430370118 -README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d +README.md: 095a5dc48ad1e762aabf71db0216fa50aceb6211 +README.zh.md: baeb0214501e9c2a13a0a272a37a6ead75f6c8d6 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 5b0c1b2dca..095a5dc48a 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Public API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for a non-empty, composition-owned route set. Registration is all-or-nothing, is disposed with the calling fiber, and returns an explicit disposer. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. @@ -19,7 +19,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route later changes ownership. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 5f5c8142ec..baeb021450 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为一组非空且由组合拥有的路由注册一个适配器实例。注册要么全部成功,要么全部不生效,会随调用 fiber 一起 dispose(资源释放),并返回显式释放器。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 @@ -19,7 +19,7 @@ - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。 -`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 +`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使路由后来更换所有者也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 3759f9f8df..1f29cfc607 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,30 +184,6 @@ export abstract class LlmAdapter { abstract stream(options: GenerateOptions): AsyncIterable } -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -export interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} - /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -225,79 +201,39 @@ export class LlmService extends Service { * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. + * @returns the disposer that unregisters all routes. */ - registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { - // The routes this registration currently holds; `replace` rewrites it, and - // the disposer releases whatever it holds at disposal time. - const owned = new Set() - // The disposer has run: `owned` being empty cannot say so on its own, - // because `replace([])` legally leaves a live registration holding none. - let released = false + registerAdapter(providers: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') - this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) + const unique = new Set() + const registrations: AdapterRegistration[] = [] + for (const provider of providers) { + if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') + if (unique.has(provider) || this.adapters.has(provider)) { + throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') + } + const info = adapter.providerInfo(provider) + if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { + throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') + } + unique.add(provider) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) + } + for (const registration of registrations) this.adapters.set(registration.provider.id, registration) yield () => { - released = true - for (const provider of owned) this.adapters.delete(provider) - owned.clear() + for (const provider of providers) this.adapters.delete(provider) } }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. - const handle = (() => void dispose()) as AdapterRegistrationHandle - handle.replace = (next: string[]): void => { - // Registering here would leak: the effect's disposer already ran, so - // nothing remains to release whatever this call would put in the map. - if (released) { - throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED') - } - this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) - } - return handle - } - - /** - * Validate one candidate route set for `adapter`, treating routes this - * registration already holds as available. Nothing is mutated: a rejected - * candidate leaves the registry exactly as it was. - */ - private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet): AdapterRegistration[] { - const unique = new Set() - const registrations: AdapterRegistration[] = [] - for (const provider of providers) { - if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') - if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) { - throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') - } - const info = adapter.providerInfo(provider) - if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { - throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') - } - unique.add(provider) - const retryPolicy = adapter.providerRetryPolicy(provider) - ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) - registrations.push({ - adapter, - provider: { id: info.id, name: info.name }, - retryPolicy, - }) - } - return registrations - } - - /** - * Swap this registration's routes for the prepared ones in one synchronous - * section, so no observer can see the registry between the release and the - * re-registration. - */ - private commitRoutes(owned: Set, registrations: readonly AdapterRegistration[]): void { - for (const provider of owned) this.adapters.delete(provider) - owned.clear() - for (const registration of registrations) { - this.adapters.set(registration.provider.id, registration) - owned.add(registration.provider.id) - } + return () => void dispose() } /** diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index f723c7f474..a5d755bdcf 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -217,7 +217,7 @@ describe('LlmService', () => { ) }) - it('keeps the serving registration policy on an in-flight call after route replacement', async () => { + it('keeps the serving registration policy on an in-flight call after route re-registration', async () => { const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') const entered = Promise.withResolvers() @@ -1382,31 +1382,4 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([]) }) - it('refuses to replace routes on a registration that was already released', async () => { - // The leak this prevents: the effect's disposer has run, so a route added - // afterwards would sit in the registry with nothing left to release it. - const ctx = new Context() - await ctx.plugin(LlmService) - - const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) - handle() - expect(() => { handle.replace(['leaked']) }) - .toThrow(/disposed adapter registration cannot replace its routes/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it('still allows an empty route set on a live registration', async () => { - // `replace([])` is the settings-section-emptied case: legal, and it must - // not be mistaken for disposal by the guard above. - const ctx = new Context() - await ctx.plugin(LlmService) - - const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) - handle.replace([]) - expect(ctx.llm.listProviders()).toEqual([]) - handle.replace(['m2']) - expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }]) - handle() - expect(ctx.llm.listProviders()).toEqual([]) - }) }) diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 0040b65507..aefb1ccd33 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -27,7 +27,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -39,7 +38,6 @@ "yaml": "^2.9.0" }, "devDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 8043e6db45..8f41f17171 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,10 +10,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -96,6 +96,17 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** Writer-lock retry constants for the private settings document protocol. */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z = z.object({ @@ -180,11 +191,8 @@ export class SettingsLocal extends Settings { } private async persistSection(ns: SettingsNamespace, section: Record): Promise { - // The writer lock's exclusive create needs the parent to exist before - // writeFileAtomic gets its own chance to create it. - // 0700: the harness home holds user-private documents. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { + await this.withWriterLock(async () => { // Read-modify-write: fold in any on-disk state this process has not // observed yet — an external edit still inside the watcher debounce // window, a change the watcher missed, or another process's write — so @@ -195,16 +203,64 @@ export class SettingsLocal extends Settings { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) - // 0600: a document that may hold personal values is never world-readable. - await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) + const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` + // TODO(settings-atomic-durability): Use a replacement that fsyncs the file + // and parent directory and preserves owner-only permissions on Windows. + try { + await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) + await rename(temp, this.spec.filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } this.text = output - }, { - onStaleBreak: (lockPath) => { - this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - }, }) } + /** Hold the private cross-process writer lock around one read-render-rename cycle. */ + private async withWriterLock(operation: () => Promise): Promise { + const lockPath = `${this.spec.filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await this.lockAgeMs(lockPath) + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe + // acquisition and release so a slow writer cannot remove a successor's lock. + this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolvePause => setTimeout(resolvePause, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + + /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ + private async lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined + } + } + override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { // The base init loads and publishes; a parse failure there is a boot // failure: an existing-but-invalid document must fail loud, never be diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts index b59b798298..3bd07a523c 100644 --- a/packages/settings/settings-local/src/invariant.ts +++ b/packages/settings/settings-local/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this provider's contracts are file round-trip, - * watcher timing, and atomic-write behavior — IO effects proven by package + * watcher timing, and atomic replacement behavior — IO effects proven by package * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. */ const install: InvariantInstaller = () => {} diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json index cf5b68fc11..67a746c982 100644 --- a/packages/settings/settings-local/tsconfig.json +++ b/packages/settings/settings-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../util/atomic-write" - }, { "path": "../../util/paths" }, diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 241bc41bfa..8a689979c1 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -546,34 +546,14 @@ export abstract class Settings extends Service { } } -/** - * Value mirror of the `FiberState` members {@link isUnloading} compares - * against: a const enum has no runtime object to import, and the value is - * needed at runtime (same rationale as the CLI boot driver's mirror). - */ -const FIBER_DISPOSED = 4 -const FIBER_UNLOADING = 5 - -/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */ -function isUnloading(ctx: Context): boolean { - const state: number = ctx.fiber.state - return state === FIBER_UNLOADING || state === FIBER_DISPOSED -} - /** Hooks a consumer hands to {@link installSettingsSection}. */ export interface SettingsSectionHooks { /** * Receive the active configuration source: the resolved settings scope - * while one is attached, the composition entry otherwise. Called before - * the matching `onChange` at attach and at detach. + * while one is attached, the composition entry otherwise. * @param current - thunk returning the currently authoritative value. */ setSource(current: () => T): void - /** - * Re-judge anything derived from the source — registration-level facts, - * memoized resolutions — after an attach, a detach, or a committed change. - */ - onChange(): void } /** @@ -581,8 +561,10 @@ export interface SettingsSectionHooks { * service exists, register `ns` with the consumer's composition entry as the * `base` layer and point the source thunk at the resolved scope; when the * service goes away (disposal, provider reload), fall back to the entry so - * the consumer keeps working exactly as composed. The registration rides the - * scoped fiber, so no settings service ever mounted means none of this runs. + * the consumer keeps working exactly as composed. The returned source is live: + * callers read committed changes from `scope.get()` without a change callback. + * The registration rides the scoped fiber, so no settings service ever mounted + * means none of this runs. * @param ctx - consumer plugin context owning the wiring. * @param ns - the consumer-owned settings namespace. * @param schema - schema resolving the namespace (typically the plugin Config). @@ -600,24 +582,7 @@ export function installSettingsSection( const scope = sctx.settings.register(ns, schema, { base: entry }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { - // This disposer runs for two different reasons. A settings provider - // detaching leaves the consumer running, so it must fall back to its - // composition entry and re-judge what it derived. The consumer's own - // unload runs it too — and there `onChange` would re-register routes - // and touch resources the teardown is releasing, so the fallback is - // pointless and the notification actively harmful. - if (isUnloading(ctx)) return hooks.setSource(() => entry) - hooks.onChange() - }) - hooks.onChange() - scope.watch(() => { - // A stored change landing while the consumer unloads reaches the watcher - // before the registration is released, and `onChange` is exactly as - // harmful here as in the disposer above: it re-registers routes against - // a fiber whose resources are being let go. - if (isUnloading(ctx)) return - hooks.onChange() }) }) } diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 9e3a8dd7e5..e6da741512 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -662,98 +662,23 @@ describe('installSettingsSection', () => { const ctx = new Context() const entry = { theme: 'entry' } let current: () => { theme: string } = () => entry - let changes = 0 installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { setSource: (source) => { current = source }, - onChange: () => { - changes += 1 - }, }) // No settings service mounted: nothing ran, the entry stays authoritative. expect(current()).toEqual({ theme: 'entry' }) - expect(changes).toBe(0) const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) await fiber await vi.waitFor(() => { expect(current()).toEqual({ theme: 'user' }) }) - expect(changes).toBe(1) - await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) - await vi.waitFor(() => { - expect(changes).toBe(2) - }) expect(current()).toEqual({ theme: 'live' }) await fiber.dispose() - await vi.waitFor(() => { - expect(changes).toBe(3) - }) expect(current()).toEqual({ theme: 'entry' }) }) - - it('stays silent when the consumer itself unloads', async () => { - const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - // The consumer's own teardown must not re-derive anything: an onChange - // here would re-register routes and touch resources being released. - await consumer.dispose() - await new Promise(resolve => setTimeout(resolve, 20)) - expect(changes).toEqual(['user']) - }) - - it('stays silent for a stored change that lands while the consumer unloads', async () => { - // The watcher outlives the start of teardown by the width of the unload, - // so a document change arriving in that window reaches it. Notifying then - // is exactly as harmful as notifying from the disposer. - const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - const unloading = consumer.dispose() - provider.pushExternal({ 'helper-ns': { theme: 'racing' } }) - await unloading - expect(changes).toEqual(['user']) - }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c670928ba7..b0eda21b88 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7 -README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e +README.md: 1c060e8a518141ae2a25a3b2bb7a7597a18052ae +README.zh.md: a7af0c4cade5049b282cbf8136ffef07e716ad56 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 1beffd6fbf..1c060e8a51 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -27,7 +27,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`.env`** — the credential document read on demand by [`dsh-credentials-local`](../../credentials/credentials-local/README.md) alone. No surface hoists it into `process.env`: doing so would turn every stored key into an ambient launch override and hide later file rotations. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. - **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d9ce9774b9..a7af0c4cad 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -27,7 +27,7 @@ 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:只由 [`dsh-credentials-local`](../../credentials/credentials-local/README.md) 按需读取的凭据文档。没有任何表层会把它提升进 `process.env`:那样做会把每个已存密钥变成环境中的启动时覆盖,并使此后在文件中轮换的值不可见。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据提供方的组合仍然只从这两者解析密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index add6070a27..8bd1ff35b2 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9 -README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77 +README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0 +README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0 diff --git a/packages/util/README.md b/packages/util/README.md index 46904aba70..605c3dd0be 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,7 +10,6 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | -| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | | `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 59a2dcf792..5c66ded33a 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,7 +10,6 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | -| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | | `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml deleted file mode 100644 index ffa4d7ccbb..0000000000 --- a/packages/util/atomic-write/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/util/atomic-write/README.md -README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc -README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md deleted file mode 100644 index be9f896eb2..0000000000 --- a/packages/util/atomic-write/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# dsh-atomic-write - -English | [中文](README.zh.md) - -Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`). - -## Surface - -```ts -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' - -declare const text: string -declare const render: (previous: string) => string - -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - -// Read-modify-write against the same file from several processes. -await withFileLock('/home/u/.dsh/settings.yaml', async () => { - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) -}) -``` - -`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it: - -- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. -- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). -- **`rename` replaces a symlinked target itself**, never writing through to its referent. -- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. -- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. - -`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs. - -## Model Experience - -None, as this is a pure filesystem primitive; nothing here reaches a model request. - -#### KV Cache effect - -None; nothing here enters a request prefix. - -## Known Limitations and Deferred Work - -- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. -- **String content only** — no `Buffer` or stream form until a consumer needs one. -- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md deleted file mode 100644 index 19a067dc84..0000000000 --- a/packages/util/atomic-write/README.zh.md +++ /dev/null @@ -1,45 +0,0 @@ -# dsh-atomic-write - -[English](README.md) | 中文 - -零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 - -## 接口面 - -```ts -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' - -declare const text: string -declare const render: (previous: string) => string - -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - -// Read-modify-write against the same file from several processes. -await withFileLock('/home/u/.dsh/settings.yaml', async () => { - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) -}) -``` - -`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出: - -- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 -- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 -- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 -- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 -- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 - -`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。 - -## Model Experience - -无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 - -#### KV Cache effect - -无;此处没有任何内容会进入请求前缀。 - -## Known Limitations and Deferred Work - -- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 -- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 -- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json deleted file mode 100644 index 147ecb1e05..0000000000 --- a/packages/util/atomic-write/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-atomic-write", - "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts deleted file mode 100644 index 07d0276033..0000000000 --- a/packages/util/atomic-write/src/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Zero-dependency atomic file replacement and writer coordination. - * `writeFileAtomic` writes a random-suffix sibling with exclusive create and - * the caller's permission bits, then renames it over the target, so readers - * observe either the old or the new complete content and a replaced file ends - * up with exactly the stated mode. `withFileLock` serializes cross-process - * writers of one file through a `wx`-created `.lock` sibling, so a - * read-modify-write cycle can never resurrect a state another writer just - * replaced; readers stay lock-free because the rename commit is atomic. - * @module @deepseek-ai/dsh-atomic-write - */ - -import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' -import { dirname } from 'node:path' - -/** - * Filesystem options for {@link writeFileAtomic}; `mode` is required so the - * permission decision stays visible at every call site. - */ -export interface WriteFileAtomicOptions { - /** - * Permission bits stamped on the fresh temp inode and carried through the - * rename (subject to the process umask, like every fresh inode). - */ - mode: number - /** - * Permission bits for parent directories this call creates (subject to the - * umask; existing directories keep their mode). Omission uses the mkdir - * default — pass `0o700` when the tree holds user-private data. - */ - dirMode?: number -} - -/** - * Replace `filename` with `content` in one atomic step, creating parent - * directories. The content is first written to a random-suffix sibling opened - * with exclusive create (`wx`): the open refuses to follow a symlink planted - * at the temp path, and the fresh inode carries `options.mode` through the - * rename, so replacing a wider-permission file narrows it without a chmod - * race. The rename also replaces a symlinked target itself instead of writing - * through to its referent, and the same-directory sibling keeps the rename on - * one filesystem. On any failure the temp file is removed and the failure - * rethrown. Crash durability (fsync) is out of scope. - * @param filename - final path receiving the content. - * @param content - complete next file content. - * @param options - permission bits for the replacement inode. - */ -export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { - await mkdir(dirname(filename), { - recursive: true, - ...options.dirMode === undefined ? {} : { mode: options.dirMode }, - }) - // TODO(settings-atomic-durability): Use a replacement that fsyncs the file - // and parent directory and preserves owner-only permissions on Windows. - const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) - await rename(temp, filename) - } catch (error) { - await rm(temp, { force: true }) - throw error - } -} - -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - -/** Whether a filesystem error means absence. */ -function isENOENT(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' -} - -/** - * Writer-lock protocol constants. These are robustness invariants of the - * cross-process write protocol, not deployment tunables: a holder rewrites one - * small file in milliseconds, so contention resolves well inside the retry - * deadline, and a lock older than the stale age can only belong to a crashed - * holder. - */ -const LOCK_RETRY_INITIAL_MS = 20 -const LOCK_RETRY_MAX_MS = 200 -const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 - -/** Options for {@link withFileLock}. */ -export interface WithFileLockOptions { - /** - * Called once each time a stale (crashed-holder) lock is broken, so the - * caller can log the takeover in its own voice. - */ - onStaleBreak?: (lockPath: string) => void -} - -/** Age of the lock file, or `undefined` when it vanished after a failed create. */ -async function lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } -} - -/** - * Hold the cross-process writer lock for `filename` around one operation. The - * lock is a `wx`-created sibling (`.lock`); paired with the - * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and - * only writers contend. Contention backs off exponentially; a lock older than - * the stale age is a crashed holder and is broken (see - * {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline - * fails the operation with a timed-out error. The parent directory must exist. - * @param filename - the file whose writers this lock serializes. - * @param operation - the read-render-commit cycle to run while holding the lock. - * @param options - stale-takeover notification hook. - * @returns the operation's result; the lock releases on both outcomes. - */ -export async function withFileLock( - filename: string, - operation: () => Promise, - options?: WithFileLockOptions, -): Promise { - const lockPath = `${filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS - let delay = LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error) { - if (!isEEXIST(error)) throw error - } - const ageMs = await lockAgeMs(lockPath) - // The holder released between the failed create and the stat: the lock is - // free right now, so retry without burning backoff or deadline. - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { - // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe - // acquisition and release so a slow writer cannot remove a successor's lock. - options?.onStaleBreak?.(lockPath) - await rm(lockPath, { force: true }) - continue - } - if (Date.now() >= deadline) { - throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } -} diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts deleted file mode 100644 index 4027dd9bda..0000000000 --- a/packages/util/atomic-write/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. - * @module @deepseek-ai/dsh-atomic-write/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' - -/** Cordis companion plugin name. */ -export const name = 'atomic-write-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime - * data; its replacement contract is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts deleted file mode 100644 index 2bc9d3ab6a..0000000000 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { writeFileAtomic } from '../src/index.ts' - -async function scratch(): Promise { - return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) -} - -describe('writeFileAtomic', () => { - it('creates the file and its parents with exactly the stated mode', async () => { - const dir = await scratch() - const target = join(dir, 'nested', 'deep', 'doc.yaml') - await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) - expect(await readFile(target, 'utf8')).toBe('a: 1\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) - }) - - it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { - const dir = await scratch() - const target = join(dir, 'doc.yaml') - await writeFile(target, 'old', { mode: 0o644 }) - await writeFileAtomic(target, 'new', { mode: 0o600 }) - expect(await readFile(target, 'utf8')).toBe('new') - expect((await stat(target)).mode & 0o777).toBe(0o600) - }) - - it('replaces a symlinked target itself without writing through to the referent', async () => { - const dir = await scratch() - const victim = join(dir, 'victim') - await writeFile(victim, 'victim-content') - const target = join(dir, 'doc.yaml') - await symlink(victim, target) - await writeFileAtomic(target, 'replaced', { mode: 0o600 }) - expect((await lstat(target)).isSymbolicLink()).toBe(false) - expect(await readFile(target, 'utf8')).toBe('replaced') - expect(await readFile(victim, 'utf8')).toBe('victim-content') - }) - - it('leaves no temp sibling and rethrows when the rename fails', async () => { - const dir = await scratch() - const target = join(dir, 'occupied') - await mkdir(target) - await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() - expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) - }) -}) diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts deleted file mode 100644 index c80346762c..0000000000 --- a/packages/util/atomic-write/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as AtomicWriteInvariant from '../src/invariant.ts' - -describe('atomic-write invariant companion', () => { - it('registers its explained empty runtime invariant', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - const fiber = await ctx.plugin(AtomicWriteInvariant) - - expect(() => { - ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) - }).toThrow(/already registered/) - await fiber.dispose() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json deleted file mode 100644 index d970a00263..0000000000 --- a/packages/util/atomic-write/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e0ba77304..ad035f6e18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,9 +273,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-pi-ai': - specifier: workspace:^ - version: link:../../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry @@ -2318,9 +2315,6 @@ importers: packages/credentials/credentials-local: dependencies: - chokidar: - specifier: ^4.0.3 - version: 4.0.3 dotenv: specifier: ^17.2.0 version: 17.4.2 @@ -2328,9 +2322,6 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: - '@deepseek-ai/dsh-atomic-write': - specifier: workspace:^ - version: link:../../util/atomic-write '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials @@ -4334,9 +4325,6 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: - '@deepseek-ai/dsh-atomic-write': - specifier: workspace:^ - version: link:../../util/atomic-write '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5505,15 +5493,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/atomic-write: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/brand: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b9dbb501ec..4c58a2a261 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -33,7 +33,6 @@ export const LINK_MAP: Readonly> = { MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', - AdapterRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', @@ -193,8 +192,6 @@ export const LINK_MAP: Readonly> = { SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', CredentialRef: 'credentials.md', - CredentialInfo: 'credentials.md', - ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3b84c91e9c..ee7a6899ca 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,11 +31,6 @@ "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "AdapterRegistrationHandle", - "source": "packages/llm/llm/src/index.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", @@ -1378,16 +1373,6 @@ "doc": "docs/core-data-structures/credentials.md", "symbol": "CredentialRef", "source": "packages/credentials/credentials/src/index.ts" - }, - { - "doc": "docs/core-data-structures/credentials.md", - "symbol": "ResolvedCredential", - "source": "packages/credentials/credentials/src/index.ts" - }, - { - "doc": "docs/core-data-structures/credentials.md", - "symbol": "CredentialInfo", - "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f5cab677be..c11c6b6d48 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -106,7 +106,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, - 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d255271dfa..3750979247 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -61,7 +61,6 @@ { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, - { "path": "./packages/util/atomic-write" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, From afb05b40495331bc190f02181bb2cfd83b75849b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:14:43 +0800 Subject: [PATCH 55/85] fix(settings-local): never steal writer locks --- ...30-settings-write-path-integrity.i18n.yaml | 4 +- ...026-07-30-settings-write-path-integrity.md | 7 ++-- ...-07-30-settings-write-path-integrity.zh.md | 9 ++--- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 3 +- packages/settings/settings-local/README.zh.md | 3 +- packages/settings/settings-local/src/index.ts | 22 +---------- .../settings-local/tests/concurrency.spec.ts | 21 ++++------ .../settings-local/tests/lock-race.spec.ts | 38 ++++++------------- 9 files changed, 36 insertions(+), 75 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index fa54d657a9..f0de393d84 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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-30-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc -2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a +2026-07-30-settings-write-path-integrity.md: f6b39ebc323e635945d2eae049c0d29e94c390b6 +2026-07-30-settings-write-path-integrity.zh.md: de60cc05751893d1875f2a68a8b357c5572940ad diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index 07bd095162..f6b39ebc32 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. Readers never lock because rename commits atomically. A contender never removes a lock it did not create: age cannot distinguish an abandoned lock from a slow live holder, and deleting by age can also remove a successor acquired between inspection and deletion. Contention therefore rejects at the deadline, leaving an abandoned lock for explicit operator recovery. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. @@ -24,7 +24,8 @@ Review found the provider's write path could destroy state it never observed, an ## Alternatives considered -- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create/backoff loop with deterministic tests. The policy favors dependencies that delete owned code; this one would replace explained local behavior with an opaque peer. +- **Age-based stale-lock takeover** — age is not ownership. A slow holder may legitimately cross the threshold, and an inspector can delete a successor's newly acquired lock after the old holder releases. Failing closed preserves mutual exclusion; recovery is explicit because only the operator can establish that no writer remains. - **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. - **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. - **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. @@ -32,4 +33,4 @@ Review found the provider's write path could destroy state it never observed, an ## Consequences -`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. +`update()` has documented failure modes for lock-deadline expiry and an invalid on-disk document, and rejection messages carry `$`-rooted paths. An abandoned lock blocks writes until an operator confirms that no writer owns it and removes the sidecar. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 5d02177073..de60cc0575 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。读方从不加锁,因为 rename 会原子提交。竞争者绝不移除并非由自己创建的锁:锁龄无法区分遗留锁与仍存活的慢速持有者,按锁龄删除还可能移除检查与删除之间由后继者取得的新锁。因此,竞争会在期限到达时拒绝写入,把遗留锁留给操作者显式恢复。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 @@ -28,7 +28,8 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 曾考虑的替代方案 -- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个较小的独占创建/退避循环,带确定性测试。该政策偏向能删除自有代码的依赖;这个依赖只会把解释清楚的本地行为换成一个不透明的等价物。 +- **按锁龄接管陈旧锁**——锁龄不等于所有权。慢速持有者可能合理地跨过阈值,而旧持有者释放后,检查方还可能删除后继者新取得的锁。以失败收口可以保住互斥性;恢复必须显式进行,因为只有操作者才能确认已无写入方存活。 - **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 - **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 - **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 @@ -36,6 +37,4 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 后果 -`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 - -[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 +`update()` 对锁获取期限到达与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。遗留锁会阻塞写入,直到操作者确认没有写入方拥有它并移除伴随文件。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。 diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 9638a62f96..2767afc5bf 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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/settings/settings-local/README.md -README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257 -README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68 +README.md: 39c2254caac720149d2fbf04d067e6154b82a671 +README.zh.md: 9fcc1319a35d56a965eb756737175dc89518c0e5 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index 2c0817afd2..39c2254caa 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -19,7 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. - **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit. -- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent. +- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender never removes a lock it does not own; it rejects at the deadline instead. Readers never take the lock: the rename commit is atomic, so reloads are always consistent. - **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. @@ -38,6 +38,7 @@ No direct invalidation; the consuming plugin owns any request-prefix changes. ## Known Limitations and Deferred Work - **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check. +- **An abandoned writer lock requires operator recovery** — lock age cannot prove ownership, so writers fail closed after 2 s instead of deleting an old lock that may still protect a slow holder; remove `.lock` only after establishing that no writer owns it. - **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart. - **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described. - **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 547abb0353..9fcc1319a3 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -19,7 +19,7 @@ - **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 - **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。 -- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 +- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避与 2 s 的获取期限。竞争者绝不移除不归自己所有的锁,而会在期限到达时拒绝写入。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 - **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。 - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 @@ -38,6 +38,7 @@ ## Known Limitations and Deferred Work - **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 +- **遗留的写锁需要操作者恢复** — 锁的存续时间无法证明所有权,因此写入方会在 2 s 后以失败收口,不会删除一把可能仍在保护慢速持有者的旧锁;只有确认没有写入方拥有 `.lock` 后才能将其移除。 - **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 - **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。 - **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 8f41f17171..d0b8e1980c 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -11,7 +11,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -105,7 +105,6 @@ function isEEXIST(error: unknown): boolean { const LOCK_RETRY_INITIAL_MS = 20 const LOCK_RETRY_MAX_MS = 200 const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { @@ -229,15 +228,6 @@ export class SettingsLocal extends Settings { } catch (error) { if (!isEEXIST(error)) throw error } - const ageMs = await this.lockAgeMs(lockPath) - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { - // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe - // acquisition and release so a slow writer cannot remove a successor's lock. - this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - await rm(lockPath, { force: true }) - continue - } if (Date.now() >= deadline) { throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) } @@ -251,16 +241,6 @@ export class SettingsLocal extends Settings { } } - /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ - private async lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } - } - override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { // The base init loads and publishes; a parse failure there is a boot // failure: an existing-but-invalid document must fail loud, never be diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index ab09866819..1c6996434a 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -70,25 +70,20 @@ describe('writer lock', () => { expect(await readFile(path, 'utf8')).toContain('value: 7') }) - it('breaks a stale writer lock with a warning and writes through', async () => { + it('does not steal an old writer lock', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') + await writeFile(path, 'alpha:\n value: 4\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - await writeFile(`${path}.lock`, 'crashed-holder\n') + const lockPath = `${path}.lock` + await writeFile(lockPath, 'slow-holder\n') const past = (Date.now() - 60_000) / 1000 - await utimes(`${path}.lock`, past, past) - await scope.update({ value: 9 }) - expect(await readFile(path, 'utf8')).toContain('value: 9') - }) + await utimes(lockPath, past, past) - it('times out on a lock a live holder never releases', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - await writeFile(`${path}.lock`, 'busy-holder\n') - await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/) + await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/) + expect(await readFile(path, 'utf8')).toContain('value: 4') + expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') }, 10_000) it('surfaces a non-contention lock failure as the write error', async () => { diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts index 09eb025654..6222041e4c 100644 --- a/packages/settings/settings-local/tests/lock-race.spec.ts +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -1,6 +1,6 @@ // Writer-lock races that cannot be timed from outside: a contender whose lock -// vanishes between the failed exclusive create and the stat, a stat failing -// for a reason other than absence, and a temp-file write failing mid-cycle. +// vanishes after the failed exclusive create and a temp-file write failing +// mid-cycle. // The fs/promises seam is partially mocked to inject exactly one failure at a // chosen path suffix; everything else passes through to the real filesystem. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -14,27 +14,23 @@ import { SettingsLocal } from '../src/index.ts' const state = vi.hoisted(() => ({ /** One-shot failure injections keyed by operation, matched on a path suffix. */ - failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>, + failures: [] as Array<{ suffix: string; code: string }>, })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() - const inject = (op: 'writeFile' | 'stat', path: unknown): void => { - const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix)) + const inject = (path: unknown): void => { + const index = state.failures.findIndex(f => String(path).endsWith(f.suffix)) if (index === -1) return const [failure] = state.failures.splice(index, 1) - throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code }) + throw Object.assign(new Error(`${failure!.code}: injected writeFile failure`), { code: failure!.code }) } return { ...actual, writeFile: (async (path: unknown, ...rest: never[]) => { - inject('writeFile', path) + inject(path) return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) }) as typeof actual.writeFile, - stat: (async (path: unknown, ...rest: never[]) => { - inject('stat', path) - return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) - }) as typeof actual.stat, } }) @@ -62,36 +58,24 @@ async function boot(config: ConstructorParameters[1]): Pro } describe('writer-lock races', () => { - it('retries immediately when the contending lock vanished before the stat', async () => { + it('retries when the contending lock vanished after the failed create', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - // The exclusive create loses to a holder that releases before the stat: - // no lock file actually exists, so the stat sees honest absence and the - // very next attempt takes the lock. - state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + // The exclusive create loses once, but no lock remains by the retry. + state.failures.push({ suffix: '.lock', code: 'EEXIST' }) await scope.update({ value: 3 }) expect(await readFile(path, 'utf8')).toContain('value: 3') }) - it('propagates a stat failure that does not mean absence', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) - state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' }) - await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/) - }) - it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') await writeFile(path, 'alpha:\n value: 1\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' }) + state.failures.push({ suffix: '.tmp', code: 'ENOSPC' }) await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/) // The document is untouched and the writer lock was released on the way out. expect(await readFile(path, 'utf8')).toContain('value: 1') From a9e2489db88f3cc98a12ee2096532314ddd895ce Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:27:12 +0800 Subject: [PATCH 56/85] fix(llm): keep request generations coherent --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 4 +- ...request-level-llm-config-credentials.zh.md | 4 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 8 +-- ...l-boundaries-and-atomic-registration.zh.md | 10 +-- ...redentials-and-static-llm-routes.i18n.yaml | 4 +- ...-only-credentials-and-static-llm-routes.md | 2 +- ...ly-credentials-and-static-llm-routes.zh.md | 2 +- docs/config-catalog.md | 14 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 8 +-- packages/llm/llm-deepseek/README.zh.md | 8 +-- packages/llm/llm-deepseek/src/adapter.ts | 51 ++++++++------- packages/llm/llm-deepseek/src/index.ts | 47 +++++++++----- .../llm/llm-deepseek/tests/adapter.spec.ts | 9 ++- .../llm-deepseek/tests/dynamic-config.spec.ts | 65 ++++++++++++------- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 12 ++-- packages/llm/llm-pi-ai/README.zh.md | 12 ++-- packages/llm/llm-pi-ai/src/adapter.ts | 23 ++++--- packages/llm/llm-pi-ai/src/config.ts | 4 +- packages/llm/llm-pi-ai/src/index.ts | 26 ++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 42 ++++++++++++ .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 4 +- 26 files changed, 237 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index 4ec3f9de2d..530f8e95f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: ec00b52bdbe8f00d334618f3e5347974a3928e67 -2026-07-29-request-level-llm-config-credentials.zh.md: 29835b9fb320e6b31cb49a632ff0e56d88fb3f44 +2026-07-29-request-level-llm-config-credentials.md: d84de8b74eb88720d782dfa20e193e6d180843dc +2026-07-29-request-level-llm-config-credentials.zh.md: 67b2d2e7cf952a7cb385e7c9dd67bd3a05651beb diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index ec00b52bdb..d84de8b74e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -12,11 +12,11 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Decision -**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the operation, while an in-flight stream keeps the facts it started with. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. Provider routes and their retry policies are composition-fixed instead of triggering registration swaps. +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the stream, while an in-flight stream keeps the facts it started with. Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are composition-fixed. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. **Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps, and a bad live snapshot keeps the last good request facts while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override request facts for those routes but cannot add or remove them. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps. A live snapshot that changes a fixed fact or fails another bound keeps the whole last-good generation, while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override only live request facts for those routes. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 29835b9fb3..67b2d2e7cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实在操作期间读取,进行中的流则保持其起始事实。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。提供方路由及其重试策略由组合固定,不触发注册替换。 +**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实按流读取,进行中的流则保持其起始事实。模型 catalog/能力、上下文、推理(reasoning)默认值、提供方路由与重试策略由组合固定。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。 **机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam,最后仅在未挂载 seam 时读取点名的原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤。存活快照若更改固定事实或违反其他约束,会整代沿用最后可用设置;错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层只能覆盖这些路由的实时请求事实。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index f7fde2b4e7..1c3eaa76a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 09beda90d4789f951f663a3f9df794d74db2c11e -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 84f3826ba8a8b89fd5dd616164ac573151b8203c +2026-07-30-credential-boundaries-and-atomic-registration.md: 2a808048bccc619a311e61340b5aeb0f3113030d +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 4c2147ea0f0d30624500aeda92fb02932a5b0960 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 09beda90d4..2a808048bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -1,4 +1,4 @@ -# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration +# Agent Note: credential boundaries and whole-generation LLM requests Status: implemented @@ -10,7 +10,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. -Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. +Request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. Both adapters also read their full settings snapshot during asynchronous model-capability resolution and again at stream dispatch; a change between those reads could pair one generation's reasoning/capability facts with the next generation's endpoint and key. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. ## Decision @@ -18,7 +18,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. -**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. +**One request cannot straddle settings generations.** Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are captured from composition. Only connection, credential, and request-transport facts resolve live, once at stream dispatch. A live snapshot that changes any fixed fact is rejected whole, so its endpoint and key cannot combine with capability facts resolved before the change. DeepSeek's accepted snapshot carries the literal key and reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and reference. **Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched. @@ -33,4 +33,4 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Consequences -The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer, and `DeepSeekConnectionOptions` carries credential facts with its endpoint so one rejected settings generation cannot contribute only a key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. +The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer. Each adapter captures model/capability defaults from composition and resolves one live connection snapshot at dispatch, so a rejected settings generation cannot contribute only an endpoint or key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 84f3826ba8..4c2147ea0f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -1,10 +1,10 @@ -# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册 +# Agent Note: 凭据边界与 LLM(大语言模型)请求的同代一致性 Status: implemented [English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 -> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 +> 范围:对[请求级 LLM 配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 -与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 +与之并排的还有请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。两个适配器还会在异步模型能力解析期间读取一次完整 settings 快照,并在流派发时再次读取;若两次读取之间发生变更,一代的推理(reasoning)/能力事实就可能与下一代的端点和密钥拼接在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。 ## 决策 @@ -22,7 +22,7 @@ Status: implemented **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 -**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 +**一次请求不得跨 settings 代取值。**模型 catalog/能力、上下文、推理默认值、提供方路由与重试策略均从组合中捕获。只有连接、凭据与请求传输事实实时解析,并且只在流派发时解析一次。存活快照若更改任何固定事实,就会整代被拒绝,因此它的端点与密钥无法同变更前解析出的能力事实组合。DeepSeek 已接受的快照在端点旁一并携带字面密钥与引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名路由与引用。 **提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber,并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。 @@ -37,4 +37,4 @@ Status: implemented ## 后果 -本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器;`DeepSeekConnectionOptions` 将凭据事实与端点一同携带,因此一代被拒绝的 settings 不可能只贡献密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 +本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器。每个适配器从组合中捕获模型/能力默认值,并在派发时解析一份实时连接快照,因此被拒绝的 settings 代不可能只贡献端点或密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml index 0e69e9aba4..5ba2c6cc6d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.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/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md -2026-07-31-read-only-credentials-and-static-llm-routes.md: 2ebbea28c2dadeb9482c483247249ed4054749e4 -2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: ad21d93bf73ca0063eb97da8e610aa2814ee1726 +2026-07-31-read-only-credentials-and-static-llm-routes.md: 3e0d4ceb612379d2ea29068605e0f7c60f1019a4 +2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: eca740c6d58d67a11270dba427efbb3b70852d2c diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md index 2ebbea28c2..3e0d4ceb61 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md @@ -14,7 +14,7 @@ That speculative closure accounted for much of the feature's runtime and test gr `ctx.credentials` exposes only branded `CredentialRef` construction and `resolve(ref): Promise`. `credentials-local` reads the named process environment value, then parses its dotenv file on demand. It owns no mutation, description, event, watcher, cache, editor, or writer lifecycle; externally changing either source is visible to the next resolution. -LLM provider routes and their retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change request-level facts for those existing routes but cannot create, remove, or retune registrations. The shared CLI composition therefore does not mount an empty pi-ai adapter. +LLM provider routes, model/capability metadata, context limits, reasoning defaults, and retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change only connection, credential, and request-transport facts for those existing routes. A settings generation that changes a fixed fact is rejected whole. The shared CLI composition therefore does not mount an empty pi-ai adapter. The optional-settings helper only switches a consumer's source thunk between its composition entry and a live settings scope. Consumers read committed values through that thunk, so the helper needs no update watcher, derived-state callback, or teardown-state mirror. `settings-local` keeps its write protocol private instead of publishing a utility for a second writer that no longer exists. diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md index ad21d93bf7..eca740c6d5 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md @@ -14,7 +14,7 @@ Status: implemented `ctx.credentials` 只暴露品牌化 `CredentialRef` 的构造,以及 `resolve(ref): Promise`。`credentials-local` 先读取点名的进程环境值,再按需解析其 dotenv 文件。它不拥有修改、描述、事件、watcher、缓存、编辑器或写入器生命周期;从外部更改任一来源,都会在下一次解析时生效。 -LLM 提供方路由及其重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 可以更改这些现有路由的请求级事实,但不能创建、移除或重新调整注册。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 +LLM 提供方路由、模型/能力元数据、上下文限制、推理(reasoning)默认值与重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 只能更改这些现有路由的连接、凭据与请求传输事实。更改固定事实的 settings 代会整代被拒绝。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 可选 settings 辅助工具只在组合配置项与存活 settings scope 之间切换消费方的来源 thunk。消费方经该 thunk 读取已提交值,因此辅助工具不需要更新 watcher、派生状态回调或拆卸状态镜像。`settings-local` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0ae1cf6ea1..7fb62dbb3b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -630,17 +630,17 @@ export interface Config { apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ + /** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort (default `high`); `off` disables thinking per request. */ + /** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ + /** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -682,7 +682,7 @@ export interface PiAiProviderProfile { baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record - /** Provider-neutral pi-ai reasoning level. */ + /** Composition-fixed provider-neutral pi-ai reasoning default. */ reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets @@ -696,7 +696,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } ``` diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 402bc1e164..be2571097b 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 8532dab4731e25b4af777217ad7c5ffad521d924 -README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c +README.md: 9d3014c9d4b3eb4fc2d02920c0cd4577e7977c89 +README.zh.md: d7685749376f58012c7d834047dd2eae754e27b8 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8532dab473..9d3014c9d4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,9 +47,9 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und ## Dynamic configuration (settings + credentials) -Request facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts: +`resolveAdapterOptions` is the explicit resolve step from raw config to validated facts. The adapter reads live connection, credential, and request-transport facts through a thunk once per stream, so base URL, key, and idle budget changes reach the next request while an in-flight stream keeps its starting facts. The provider route, model catalog, context limits, thinking policy, reasoning default, and retry policy are composition-fixed. Two optional seams feed the live facts: -- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good request facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live snapshot that changes a composition-fixed fact or fails a resolver bound is rejected as a whole generation: it contributes neither its changed connection nor credential. The entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart. `ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration. @@ -72,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, and composition-fixed retry policy), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers, including next-request base-URL/key pickup and a change landing between capability resolution and dispatch; the latter proves a generation that changes composition facts cannot contribute a newer endpoint or key. `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -106,7 +106,7 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work -- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **Settings cannot change model/capability defaults** — catalog, context limits, thinking policy, reasoning default, and retry policy belong to composition; a settings generation that changes one is rejected whole. - **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 922fb50ae0..d768574937 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,9 +47,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值: +`resolveAdapterOptions` 是从原始配置到已校验事实的显式 resolve 步骤。适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实,因此 base URL、密钥与 idle 预算变更会作用于下一次请求,进行中的流则保持其起始事实。提供方路由、模型 catalog、上下文限制、思考策略、推理默认值与重试策略由组合固定。两个可选 seam 为实时事实供值: -- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用的请求事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活快照若更改由组合固定的事实或违反 resolver 约束,会整代被拒绝:其变更后的连接与凭据均不会被采用。entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**:API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。 `ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。 @@ -72,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照,以及由组合固定的重试策略),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider,覆盖下一请求即生效的 base-URL/密钥拾取,以及落在能力解析与派发之间的变更;后者证明,更改组合事实的一代设置无法贡献更新的端点或密钥。`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -106,7 +106,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 -- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **settings 无法更改模型/能力默认值**:catalog、上下文限制、思考策略、推理默认值与重试策略归组合所有;settings 若更改其中一项,整代设置都会被拒绝。 - **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index a4b02a3e39..adbfa23607 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,9 +1,9 @@ /** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) * chat-completions endpoint, emitting harness StreamChunks. The adapter is - * transport-only: connection facts arrive through a thunk resolved once per - * operation and the bearer token through a per-request resolver, so the - * registering plugin owns validation, layering, and credential policy. + * transport-only: live connection facts arrive through a thunk resolved once + * per stream, composition-fixed capability/default facts arrive separately, + * and the bearer token comes from a per-request resolver. * * @module dsh-llm-deepseek/adapter */ @@ -38,10 +38,9 @@ export interface DeepSeekCatalogModel { } /** - * Validated connection facts for one operation. The plugin's - * `resolveAdapterOptions` is the one explicit resolve step producing this - * shape; the adapter trusts it and re-reads it per operation, which is what - * makes a configuration change reach the next request without re-registration. + * Validated adapter facts. The plugin's `resolveAdapterOptions` is the explicit + * resolve step producing this shape; the adapter receives one composition + * snapshot plus a per-stream current snapshot. */ export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ @@ -54,22 +53,24 @@ export interface DeepSeekConnectionOptions { apiKey?: string /** Credential reference of this same resolution, resolved per request when no literal key exists. */ apiKeyEnv: CredentialRef - /** Request defaults applied to every call (thinking mode, effort). */ + /** Composition-fixed request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ + /** Composition-fixed advisory models exposed to discovery consumers; requests remain unrestricted. */ models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number - /** Provider-owned model-request retry policy, already resolved. */ + /** Composition-fixed provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } -/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */ +/** Constructor inputs for {@link DeepSeekAdapter}: live, composition, and credential facts. */ export interface DeepSeekAdapterOptions { - /** Current validated connection facts; called once per operation. */ + /** Current validated request facts; called once per stream. */ options: () => DeepSeekConnectionOptions + /** Composition snapshot owning catalog, capability/default, context, and retry facts. */ + composition: DeepSeekConnectionOptions /** * Resolve the bearer token for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come @@ -154,11 +155,11 @@ export class DeepSeekAdapter extends LlmAdapter { } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.config.options().retryPolicy + return this.config.composition.retryPolicy } override listModels(provider: string): Promise { - return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) + return Promise.resolve(this.config.composition.models.map(model => modelInfo(provider, model))) } override resolveModel( @@ -166,16 +167,16 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const connection = this.config.options() - const configured = connection.models.find(entry => entry.id === model) + const composition = this.config.composition + const configured = composition.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? connection.defaultContextWindow + ?? composition.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, - ...connection.defaults.thinking === 'disabled' + ...composition.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, @@ -185,9 +186,9 @@ export class DeepSeekAdapter extends LlmAdapter { : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: connection.defaults.reasoningEffort === 'off' + defaultEffort: composition.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT - : connection.defaults.reasoningEffort === 'max' + : composition.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, @@ -196,9 +197,9 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { - // One resolution per stream call: connection facts 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. + // One live resolution per stream call: connection, credential, and + // transport facts freeze here and hold for the request. Model capability + // and default facts come from the composition snapshot above. // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() @@ -250,7 +251,7 @@ export class DeepSeekAdapter extends LlmAdapter { connection: DeepSeekConnectionOptions, apiKey: string, ): AsyncIterable { - const body = serializeRequest(options, connection.defaults) + const body = serializeRequest(options, this.config.composition.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 0801f36747..eb90345d5e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -4,9 +4,9 @@ * load: the plugin layers its `cordis.yml` entry config under the optional * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API * key through the optional credential seam (`ctx.credentials`), so a changed - * base URL, catalog, or key reaches the very next request without restarting - * anything, while an in-flight stream keeps the facts it started with. The - * registration-captured facts stay composition-fixed. + * base URL, key, or request-transport control reaches the next request without + * restart. Catalog, capability/default, context, and retry facts stay fixed by + * composition. * @module @deepseek-ai/dsh-llm-deepseek */ @@ -15,7 +15,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -53,17 +53,17 @@ export interface Config { apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ + /** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort (default `high`); `off` disables thinking per request. */ + /** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ + /** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -90,10 +90,9 @@ export const Config: z = z.object({ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' /** - * One resolution's complete request facts. Connection and credential facts - * are one value on purpose: a snapshot the resolver rejects keeps the whole - * previous generation, so a request can never pair a stale endpoint with a - * newer key. + * One resolution's complete adapter facts. Connection and credential facts + * stay one value, while catalog, capability/default, context, and retry facts + * must equal the composition snapshot. */ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions @@ -165,8 +164,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { } } +/** Facts that must stay identical to the plugin composition for the route's lifetime. */ +function compositionFacts(options: ResolvedDeepSeekOptions): unknown { + return { + defaults: options.defaults, + ...options.defaultContextWindow === undefined + ? {} + : { defaultContextWindow: options.defaultContextWindow }, + models: options.models, + retryPolicy: options.retryPolicy, + } +} + export function apply(ctx: Context, config: Config): void { const compositionOptions = resolveAdapterOptions(config) + const fixedFacts = compositionFacts(compositionOptions) let current: () => Config = () => config let lastRaw: Config = config let lastGood = compositionOptions @@ -175,12 +187,17 @@ export function apply(ctx: Context, config: Config): void { if (raw === lastRaw) return lastGood try { const next = resolveAdapterOptions(raw) + if (!deepEqualJson(compositionFacts(next), fixedFacts)) { + throw new Error( + 'llm-deepseek: model catalog, capability defaults, context limits, and retry policy are composition-fixed', + ) + } lastRaw = raw lastGood = next return next } catch (error) { // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing a beyond-schema bound: + // only sees an invalid live snapshot or one that changes a fixed fact: // keep serving the last good facts and say so once per bad snapshot. lastRaw = raw ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') @@ -211,7 +228,7 @@ export function apply(ctx: Context, config: Config): void { ) } - const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + const adapter = new DeepSeekAdapter({ options, composition: compositionOptions, resolveApiKey }) ctx.llm.registerAdapter([PROVIDER], adapter) installSettingsSection(ctx, NS, Config, config, { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 811b9da1c6..77456c52d2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -34,8 +34,10 @@ async function harness(baseURL: string, config: object = {}) { /** Direct adapter over the plugin's real resolve step, with a static key. */ function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { const { apiKey, ...rest } = config + const composition = resolveAdapterOptions(rest) return new DeepSeekAdapter({ - options: () => resolveAdapterOptions(rest), + options: () => composition, + composition, resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), }) } @@ -882,9 +884,10 @@ describe('plugin registration and config', () => { it('resolves connection facts and the credential exactly once per stream call', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const composition = resolveAdapterOptions({ baseURL: server.url }) + const options = vi.fn(() => composition) const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) - const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + const adapter = new DeepSeekAdapter({ options, composition, resolveApiKey }) for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 9a89734c60..93db5ddce2 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -19,6 +19,7 @@ afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!() await closeMockServers() vi.unstubAllEnvs() + vi.restoreAllMocks() }) async function home(): Promise { @@ -99,15 +100,13 @@ describe('request-level dynamic configuration', () => { expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) - it('advertises a live settings catalog without re-registration', async () => { + it('keeps the model catalog composition-fixed', async () => { const dir = await home() const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'settings-model', name: 'From Settings' }, - ]) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) }) it('keeps the registration retry policy composition-fixed', async () => { @@ -133,33 +132,19 @@ describe('request-level dynamic configuration', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { - const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) - - // Schema-valid but resolver-invalid: duplicate catalog ids pass the array - // schema and fail the explicit resolve step. - await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'recovered', name: 'recovered' }, - ]) - }) - - it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { + it('rejects a settings generation that combines new composition and connection facts', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() const good = await mockServer([{ kind: 'sse', events: textEvents }]) const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url }) - // One snapshot moves the endpoint AND the literal key, and fails the - // resolve step beyond the schema (duplicate catalog ids). + // One schema-valid snapshot moves the endpoint and key while also trying + // to replace the composition-owned catalog. await ctx.settings.update(NS, { apiKey: 'rejected-key', baseURL: rejected.url, - models: [{ id: 'dup' }, { id: 'dup' }], + models: [{ id: 'settings-model' }], }) await prompt(ctx) @@ -170,6 +155,42 @@ describe('request-level dynamic configuration', () => { expect(good.headers[0]?.authorization).toBe('Bearer good-key') }) + it('cannot mix earlier capability facts with a later settings connection', async () => { + const dir = await home() + const first = await mockServer([{ kind: 'sse', events: textEvents }]) + const second = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { + apiKey: 'first-key', + baseURL: first.url, + thinking: 'disabled', + reasoningEffort: 'off', + }) + const resolveModel = vi.spyOn(LlmDeepSeek.DeepSeekAdapter.prototype, 'resolveModel') + resolveModel.mockImplementation(async function ( + this: LlmDeepSeek.DeepSeekAdapter, + provider, + model, + signal, + ) { + resolveModel.mockRestore() + const resolved = await this.resolveModel(provider, model, signal) + // Land a complete settings generation after capability resolution but + // before stream dispatch. Its changed composition fact rejects it whole. + await ctx.settings.update(NS, { + apiKey: 'second-key', + baseURL: second.url, + thinking: 'enabled', + reasoningEffort: 'max', + }) + return resolved + }) + + await prompt(ctx) + expect(second.requests).toHaveLength(0) + expect(first.headers[0]?.authorization).toBe('Bearer first-key') + expect(first.requests[0]).toMatchObject({ thinking: { type: 'disabled' } }) + }) + it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 9af6c5b499..108324818e 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: e8b7adf122946fc22f231fafb521866cbacdc652 -README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee +README.md: 972dcaed2ca18e0bef9c7c91ba1d3f518236a756 +README.zh.md: 40d5794b6d4dac3bf31e54bd6e570407b72eaa9f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e8b7adf122..972dcaed2c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -39,15 +39,15 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl ## Dynamic configuration (settings + credentials) -The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. +The adapter reads live connection, credential, and request-transport facts through a thunk once per stream. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`. The user layer can override a composition route's endpoint, credential reference, headers, budgets, cache/transport choices, and timeouts for the next request. Provider routes, installed model capabilities, reasoning defaults, and retry policies remain composition-fixed; a settings snapshot that changes a fixed fact is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes a fixed fact, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; none of its connection or credential facts leak into a request. The entry config itself fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. -The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The composition profile's `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. `reasoning` and `retryPolicy` are composition facts; the other fields are live request facts. Each optional retry policy is captured with its provider route, and omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and credential changes reach later requests, while a change landing between capability resolution and dispatch cannot combine an earlier reasoning default with a newer endpoint or key. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -111,7 +111,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes. +- **Settings cannot change routes or model defaults** — provider ownership, installed model capabilities, reasoning defaults, and retry policy are composition facts; the user layer can only change connection, credential, and request-transport fields of existing routes. - **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 5bbf034267..40d5794b6d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -39,15 +39,15 @@ ## 动态配置(settings + credentials) -适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定;settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 +适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以为下一次请求覆盖组合路由的端点、凭据引用、标头、预算、缓存/传输选项与超时。提供方路由、已安装模型的能力、推理(reasoning)默认值与重试策略始终由组合固定;settings 快照若更改固定事实,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;entry 配置本身会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改固定事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;其中的连接与凭据事实一概不会泄漏进请求。entry 配置本身会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 -`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理能力的模型也会公开 pi-ai 的 `off` 选项。组合 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。`reasoning` 与 `retryPolicy` 属于组合事实,其他字段属于实时请求事实。每个可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与凭据变更会作用于后续请求,而落在能力解析与派发之间的变更无法把较早一代的推理默认值与较新一代的端点或密钥拼接起来。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 @@ -111,7 +111,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。 +- **settings 无法更改路由或模型默认值**:提供方所有权、已安装模型的能力、推理默认值与重试策略属于组合事实;用户层只能更改现有路由的连接、凭据与请求传输字段。 - **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 030592f74c..38b853f162 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -34,10 +34,12 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ +/** Constructor inputs for {@link PiAiAdapter}: live, composition, and credential facts. */ export interface PiAiAdapterOptions { - /** Current validated profiles by provider route; called once per operation. */ + /** Current validated request profiles by provider route; called once per stream. */ profiles: () => ReadonlyMap + /** Composition snapshot owning routes, model capabilities, reasoning defaults, and retry policies. */ + compositionProfiles: 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 @@ -117,11 +119,11 @@ export class PiAiAdapter extends LlmAdapter { } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.config.profiles().get(provider)?.retryPolicy + return this.config.compositionProfiles.get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.config.profiles().get(provider) + const profile = this.config.compositionProfiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -137,7 +139,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.config.profiles().get(provider) + const profile = this.config.compositionProfiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -170,17 +172,18 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - // 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. + // One live resolution per stream call: connection, credential, and + // transport facts freeze here and hold for the request. Capability and + // default reasoning facts come from the composition snapshot. const profile = this.config.profiles().get(options.provider) - if (profile === undefined) { + const compositionProfile = this.config.compositionProfiles.get(options.provider) + if (profile === undefined || compositionProfile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolvePiModel(profile, options.model) const reasoning = resolveReasoningLevel( model, - options.reasoningEffort ?? profile.reasoning, + options.reasoningEffort ?? compositionProfile.reasoning, ) const apiKey = await this.config.resolveApiKey(options.provider, profile) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index a298873b28..1fd2354158 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -28,7 +28,7 @@ export interface PiAiProviderProfile { baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record - /** Provider-neutral pi-ai reasoning level. */ + /** Composition-fixed provider-neutral pi-ai reasoning default. */ reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets @@ -42,7 +42,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 46a4bf01f1..50255bdcdd 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -3,9 +3,9 @@ * 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 request knob - * reaches the next request without a restart. Provider routes and retry - * policies stay composition-fixed. + * optional credential seam, so a changed key, endpoint, or request-transport + * knob reaches the next request without a restart. Provider routes, model + * capabilities, reasoning defaults, and retry policies stay composition-fixed. * * ```yaml * - id: llm @@ -45,20 +45,24 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-pi-ai') /** - * The registry captures these per route; a change here must re-register. + * Composition captures these per route; a settings change cannot alter them. * Sorted by provider so a settings document that merely reorders its keys is * not mistaken for a route change. */ -function registrationFacts(profiles: ReadonlyMap): unknown { +function compositionFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] - .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + .map(([provider, profile]) => ({ + provider, + reasoning: profile.reasoning, + retryPolicy: profile.retryPolicy, + })) .sort((left, right) => left.provider.localeCompare(right.provider)) } /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const compositionProfiles = resolveProfiles(config.providers) - const compositionFacts = registrationFacts(compositionProfiles) + const fixedFacts = compositionFacts(compositionProfiles) let current: () => Config = () => config let lastRaw: Config = config let lastGood: ReadonlyMap = compositionProfiles @@ -67,15 +71,15 @@ export function apply(ctx: Context, config: Config): void { if (raw === lastRaw) return lastGood try { const next = resolveProfiles(raw.providers) - if (!deepEqualJson(registrationFacts(next), compositionFacts)) { - throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed') + if (!deepEqualJson(compositionFacts(next), fixedFacts)) { + throw new Error('llm-pi-ai: provider routes, reasoning defaults, and retry policies are composition-fixed') } 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: + // only sees an invalid live snapshot or one that changes a fixed fact: // keep serving the last good profiles and say so once per bad snapshot. lastRaw = raw ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') @@ -111,7 +115,7 @@ export function apply(ctx: Context, config: Config): void { ) } - const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + const adapter = new PiAiAdapter({ profiles, compositionProfiles, resolveApiKey }) ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter) installSettingsSection(ctx, NS, Config, config, { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 8edfaceb1a..95605961b1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -25,8 +25,10 @@ async function harness(baseURL: string, overrides: Record = {}) /** Direct adapter over the real profile resolver, with literal-key resolution. */ function adapterOf(providers: Record): PiAiAdapter { + const compositionProfiles = resolveProfiles(providers) return new PiAiAdapter({ - profiles: () => resolveProfiles(providers), + profiles: () => compositionProfiles, + compositionProfiles, resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), }) } diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index a9735e1dcf..e4e7756cd3 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -18,6 +18,7 @@ afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!() await closeMockServers() vi.unstubAllEnvs() + vi.restoreAllMocks() }) async function home(): Promise { @@ -97,4 +98,45 @@ describe('request-level dynamic profiles', () => { jitterRatio: 0.2, }) }) + + it('cannot mix earlier capability facts with a later settings connection', async () => { + const dir = await home() + const first = await mockServer([{ events: textEvents }]) + const second = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { + deepseek: { + apiKey: 'first-key', + baseURL: first.url, + reasoning: 'off', + }, + }, + }) + const resolveModel = vi.spyOn(LlmPiAi.PiAiAdapter.prototype, 'resolveModel') + resolveModel.mockImplementation(async function ( + this: LlmPiAi.PiAiAdapter, + provider, + model, + signal, + ) { + resolveModel.mockRestore() + const resolved = await this.resolveModel(provider, model, signal) + // Land a complete settings generation after capability resolution but + // before stream dispatch. Its changed reasoning default rejects it whole. + await ctx.settings.update(NS, { + providers: { + deepseek: { + apiKey: 'second-key', + baseURL: second.url, + reasoning: 'max', + }, + }, + }) + return resolved + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(second.requests).toHaveLength(0) + expect(first.headers[0]?.authorization).toBe('Bearer first-key') + }) }) 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 3f12ef4460..aa8d16e3d5 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -22,8 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) + const compositionProfiles = resolveProfiles({ openai: { apiKey: 'test-key' } }) const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + profiles: () => compositionProfiles, + compositionProfiles, resolveApiKey: () => Promise.resolve('test-key'), }) const drain = async (): Promise => { From 8933d63143d8bc1165ec49873aad22ba77f3a2cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:16:28 +0800 Subject: [PATCH 57/85] test(web): align e2e anchors and goldens with the unified tool-row expand --- apps/web/tests/cordis-tool-round.e2e.ts | 3 ++- apps/web/tests/navigation-panes.e2e.ts | 18 ++++++++++++------ .../snapshots/code-mode-round/ui.expected.md | 10 ++++++---- .../snapshots/cordis-tool-round/ui.expected.md | 15 +++++++++------ .../snapshots/fresh-round-trip/ui.expected.md | 8 ++++---- .../snapshots/message-actions/ui.expected.md | 16 ++++++++++------ .../snapshots/plan-review/approved.expected.md | 4 ++-- .../question-composer/answered.expected.md | 4 ++-- .../snapshots/seeded-history/ui.expected.md | 16 ++++++++++------ .../snapshots/steering/mid-steer.expected.md | 4 ++-- .../snapshots/steering/settled.expected.md | 5 +++-- 11 files changed, 62 insertions(+), 41 deletions(-) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index e0ad2d26e1..66bc9c6900 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -107,7 +107,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => { const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first() await mountRow.waitFor({ timeout: 10_000 }) - await mountRow.locator('button[aria-expanded]').click() + // The whole summary row is the expand toggle (unified tool-row interaction). + await mountRow.locator('[aria-expanded]').first().click() await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) .toContain(MOUNT_CODE) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index e88cfa8857..d5464af553 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -180,11 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') + // The row click is the card's expand toggle (unified tool-row + // interaction); it must not drive layout geometry either way. await bashRow.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // The card's own controls are outside the summary row and must not open - // details either — the terminal card is read in place. - await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click() + // details either — the expanded terminal card is read in place. + await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() @@ -196,10 +198,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal')) await page.getByRole('tab', { name: 'Chat' }).click() - // The card is resident in the keyed bash row (no expand gesture): the - // recorded command's own output sits in the message flow, derived from the - // logged call/result presentations alone. - const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first() + // The card is expand-gated behind the whole-row toggle (the unified + // tool-row interaction): open it if a previous case left it collapsed. + // Expanded, the recorded command's own output sits in the message flow, + // derived from the logged call/result presentations alone. + const bashRow = page.locator('[data-sample="bash-global"]').first() + await bashRow.waitFor({ timeout: 15_000 }) + if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() + const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) // Real layout, not jsdom's stub (which computes no geometry at all): // squeeze the output pane below its content width and the line must keep diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..1b6396a9cb 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -15,13 +15,15 @@ - img - img - text: "Think The user wants me to write a single `run_code` program that:" -- button: +- button "Code Run bash echo and catch missing file read": - img - img -- text: Code Run bash echo and catch missing file read + - text: Code Run bash echo and catch missing file read - img -- text: Bash Echo CODE_ROUND_OK Read -- button "missing.txt" +- text: Bash Echo CODE_ROUND_OK +- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"': + - img + - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found" - button "Think The program ran successfully. Let me now reply DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..1aee8fc03f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -15,27 +15,30 @@ - img - img - text: "Think The user wants me to:" -- button: +- button "Inspect temporary": - img - img -- text: Inspect temporary + - text: Inspect temporary - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." -- button [expanded]: +- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]': - img -- text: Mount temporary Plugin typescript + - text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" +- text: typescript - button "复制" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" +- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts). +- button "Inspect" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." -- button: +- button "Unmount temporary Plugin dyn-1": - img - img -- text: Unmount temporary Plugin dyn-1 + - text: Unmount temporary Plugin dyn-1 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..06a9a40367 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -15,10 +15,10 @@ - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- img -- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK -- button "复制" -- text: WEB_E2E_OK +- button "Bash Echo the test string": + - img + - img + - text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..09ecd0c41d 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -16,12 +16,16 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- img -- text: Read -- button "a.txt" -- img -- text: Read -- button "b.txt" +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index aca0bc31bb..84cadffe2a 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -25,10 +25,10 @@ - button "在新对话中分支": - img - text: {{clock}} -- button: +- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"': - img - img -- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI" + - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI" - 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."': - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..f9ed8017c6 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -15,10 +15,10 @@ - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. -- button: +- button "Ask question 1/1 answered": - img - img -- text: Ask question 1/1 answered + - text: Ask question 1/1 answered - button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..928c1dd879 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -15,12 +15,16 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- img -- text: Read -- button "a.txt" -- img -- text: Read -- button "b.txt" +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 28127fd73b..5c88526fe8 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -15,10 +15,10 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button: +- button "Ask question waiting": - img - img -- text: Ask question waiting + - text: Ask question waiting - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..253420f7ab 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -15,10 +15,11 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button: +- button "Ask question 1/1 answered": - img - img -- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply." + - text: Ask question 1/1 answered +- text: "插话 Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img From 72a73078260f5d7d3f8e7518b1095609c03dc51f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:28:43 +0800 Subject: [PATCH 58/85] chore: regenerate module graph and refresh composer goldens after master merge --- apps/web/tests/snapshots/plan-review/approved.expected.md | 3 +-- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 3 +-- .../web/tests/snapshots/seeded-history/command-row.expected.md | 3 +-- docs/module-graph.md | 3 ++- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index dd62e265ee..e207392640 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -35,10 +35,9 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..856cdcc525 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -14,10 +14,9 @@ - paragraph: partial - button "2 条排队消息" - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 87ffd9fc55..798b2cf3de 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -38,10 +38,9 @@ - img - text: permission preset workspace-write - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/docs/module-graph.md b/docs/module-graph.md index 8e8b3130ab..da3910f978 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -927,6 +927,7 @@ flowchart TD pkg_client_ui_plan --> pkg_client_connection pkg_client_ui_plan --> pkg_client_runtime pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode @@ -1183,7 +1184,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 4517aa349ce34821b70ab35f1fdcdbc4d2bb6863 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 16:52:54 +0800 Subject: [PATCH 59/85] fix: client module loader report package resolve info --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 4 +- ...26-07-23-client-plugin-loading-model.zh.md | 4 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 + packages/client/modules/README.zh.md | 2 + packages/client/modules/src/index.ts | 53 ++++++++++-- .../client/modules/tests/node-half.spec.ts | 85 +++++++++++++++++++ 8 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 packages/client/modules/tests/node-half.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f140786632..451dcd1fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d -2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c +2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c +2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index b0873b7aa7..02347f2964 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`. -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f3472dbfc5..ea927d3586 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 80bf46a996..c3dfc36e65 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da -README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72 +README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8 +README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index efba9e2eb0..99565b349d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook). +The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. + ## Model Experience None, as the module loader is browser-side kernel machinery; nothing here reaches a model request. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index b057bfdd8c..a8ed0a4949 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -8,6 +8,8 @@ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。 +Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 + ## 模型体验 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index ecfc31b77f..f7488ff2f4 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,40 @@ interface PkgMeta { immediately: boolean } +/** Missing built client export, retained as structured data for activation-error grouping. */ +class MissingClientBundleError extends Error { + constructor( + readonly packageName: string, + readonly clientPath: string, + cause: unknown, + ) { + super( + `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + { cause }, + ) + } +} + +/** Activation failures grouped by actionable package-build errors and unrelated failures. */ +class ClientPackageCompositionError extends AggregateError { + constructor(failures: Error[]) { + const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError) + const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError)) + const packageNoun = failures.length === 1 ? 'package' : 'packages' + const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] + if (missingBundles.length > 0) { + lines.push(' client packages requiring a build before source launch:') + for (const error of missingBundles) { + lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) + } + } + if (otherFailures.length > 0) { + lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`)) + } + super(failures, lines.join('\n')) + } +} + /** One composed table row: the wire entry plus its bundle path. */ interface WebPluginRecord { entry: WebBootEntry @@ -138,7 +172,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string { * + bundle route + index tap. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the - * boot sweep reports it). + * boot activation audit reports it). */ export class ClientModuleHostService extends Service { static inject = ['httpServer', 'loader'] @@ -194,10 +228,7 @@ export class ClientModuleHostService extends Service { const failures: Error[] = [] this.flush(err => failures.push(err)) if (failures.length > 0) { - throw new AggregateError( - failures, - `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, - ) + throw new ClientPackageCompositionError(failures) } ctx.effect( @@ -322,6 +353,16 @@ export class ClientModuleHostService extends Service { return meta } + /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + private initialBundleRevision(pkgName: string, clientPath: string): string { + try { + return shortHash(readFileSync(clientPath)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + throw new MissingClientBundleError(pkgName, clientPath, error) + } + } + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ private processOne(entryName: string): boolean { let qualifies = false @@ -337,7 +378,7 @@ export class ClientModuleHostService extends Service { if (meta === null) return false // The rev rides the row from here on: a fiber restart reuses the row (and // its rev) untouched; only rebuilt() re-reads the bundle. - const rev = shortHash(readFileSync(meta.clientPath)) + const rev = this.initialBundleRevision(entryName, meta.clientPath) this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath }) return true } diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts new file mode 100644 index 0000000000..37c41d9742 --- /dev/null +++ b/packages/client/modules/tests/node-half.spec.ts @@ -0,0 +1,85 @@ +/** Node-half composition diagnostics for package metadata and built client bundles. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import { afterEach, describe, expect, it } from 'vitest' +import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { ClientModuleHostService } from '../src/index.ts' + +let root: string | undefined + +afterEach(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + root = undefined +}) + +/** Create a resolvable dshClient package whose client export points at the returned path. */ +function writePackage(packageName: string): string { + root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-')) + const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) + const clientPath = join(pkgRoot, 'lib', 'client.js') + mkdirSync(pkgRoot, { recursive: true }) + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ + name: packageName, + exports: { + './client': './lib/client.js', + './package.json': './package.json', + }, + dshClient: { platform: 'web' }, + })) + return clientPath +} + +/** Construct the node-half service over the enabled fixture entries. */ +function construct(packageNames: string[]): ClientModuleHostService { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root!).href + '/' + ctx.provide('loader', { + *entries() { + for (const packageName of packageNames) { + yield { options: { name: packageName }, fiber: {}, disabled: false } + } + }, + }) + const httpServer: Pick = { + port: 0, + register: () => () => {}, + tapIndex: () => () => {}, + } + ctx.provide('httpServer', httpServer as HttpServerService) + return new ClientModuleHostService(ctx) +} + +describe('client bundle activation', () => { + it('groups missing bundles under one source-build instruction with a package/path list', () => { + const firstName = '@fixture/missing-first' + const secondName = '@fixture/missing-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + expect(() => construct([firstName, secondName])).toThrow([ + 'client-modules: 2 client packages failed to compose:', + ' client packages requiring a build before source launch:', + ` - package: ${firstName}`, + ` path: ${firstPath}`, + ` - package: ${secondName}`, + ` path: ${secondPath}`, + ].join('\n')) + }) + + it('does not report other bundle read failures as missing builds', () => { + const packageName = '@fixture/unreadable-client' + const clientPath = writePackage(packageName) + mkdirSync(clientPath, { recursive: true }) + let thrown: unknown + try { + construct([packageName]) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain('EISDIR') + expect(String(thrown)).not.toContain('requiring a build before source launch') + }) +}) From 296cb1c35f3a27f6b0471215a4a223783a877558 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 16:53:05 +0800 Subject: [PATCH 60/85] fix: app-boot report plugin error detail --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 4 +- ...fig-tree-boot-and-transport-layering.zh.md | 4 +- apps/cli/src/app-cli-entry.ts | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/src/index.ts | 60 +++++++++++----- packages/ui/app-boot/tests/app-boot.spec.ts | 70 ++++++++++++++++++- 9 files changed, 128 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index aa927a2e7d..dcb580be3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d +2026-07-24-web-config-tree-boot-and-transport-layering.md: 6617e0c167e39d11c2261c9b8f433f1b68185ea7 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7b693b8869613b4352d65ba7d352bd1e9fca448b diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index a2080024d3..6617e0c167 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; `installFailLoud` remains the process guard for later unhandled rejections. -**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. +**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 1c430bf293..7b693b8869 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;`installFailLoud` 继续作为进程级保护,处理之后出现的未处理 rejection。 -**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 +**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 6ac88d23c5..54ade122c7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -3,7 +3,7 @@ * for the Web/headless surface. * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud triple after the tree + * flags + the resolved frontend dist), and the fail-loud activation audit after the tree * settles. The environment is what the bin already loaded (ambient plus the * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential * provider and is never hoisted here. @@ -246,7 +246,7 @@ export class AppCLIEntry { if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) } - /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ + /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */ private async bootTree(): Promise { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c670928ba7..4126ecee12 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7 -README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e +README.md: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661 +README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 1beffd6fbf..7c4b258de4 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries | +| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. +The guards preserve two Loader failure classes. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d9ce9774b9..b1b1221779 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,14 +10,14 @@ | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 | +| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 +这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index b404809527..0351e83d65 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -192,28 +192,53 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** Runtime mirrors for Cordis's erased const-enum fiber states. */ -const FIBER_ACTIVE = 2 as FiberState.ACTIVE +/** + * Value mirrors used because Cordis's const enum has no runtime object to import. + * Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and + * `packages/client/web/src/loader-status.ts`. + */ const FIBER_PENDING = 0 as FiberState.PENDING +const FIBER_ACTIVE = 2 as FiberState.ACTIVE + +/** Render a thrown plugin value without discarding an Error's original stack. */ +function formatActivationError(error: unknown): string { + return error instanceof Error ? error.stack ?? error.message : String(error) +} /** - * Reject enabled Loader entries whose fibers did not reach ACTIVE after settle. - * @param ctx - The settled application root. - * @param binName - Diagnostic prefix. + * Reject a settled Loader tree when an enabled entry failed or remains inactive. + * Plugin failures include the original thrown stack; pending entries name their + * unresolved services because no plugin error exists for that state. + * @param ctx - the settled context whose Loader entries to audit. + * @param binName - the diagnostic prefix on the thrown error. + * @returns nothing when every enabled entry is active. + * @throws when an entry failed to import, rejected during activation, or did not become active. */ -export function assertEntriesActive(ctx: Context, binName: string): void { +export async function assertEntriesActivated(ctx: Context, binName: string): Promise { + assertEntriesLoaded(ctx, binName) const failures: string[] = [] for (const entry of ctx.loader.entries()) { - if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue - if (entry.fiber.state === FIBER_PENDING) { - const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) - failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + const fiber = entry.fiber + if (fiber === undefined || entry.disabled) continue + try { + await fiber.await() + } catch (error) { + failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + continue + } + const state = fiber.state + if (state === FIBER_ACTIVE) continue + if (state === FIBER_PENDING) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + const subject = missing.length === 1 ? 'service' : 'services' + failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`) } else { - failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`) + failures.push(`${entry.options.name}: fiber state ${String(state)}`) } } if (failures.length > 0) { - throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + const noun = failures.length === 1 ? 'entry' : 'entries' + throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) } } @@ -226,8 +251,10 @@ export function assertEntriesActive(ctx: Context, binName: string): void { * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. A missing fiber rejects here; a later init rejection is - * handled by {@link installFailLoud}. Built bins need the Loader's native - * helper for bare plugin specifiers; relative specifiers do not. + * rethrown with its original stack by {@link assertEntriesActivated}; later + * unhandled rejections remain covered by {@link installFailLoud}. Built bins + * need the Loader's native helper for bare plugin specifiers; relative + * specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -259,12 +286,11 @@ export async function boot( // A surface can finish and dispose the whole tree while that await is still // pending: the TUI renders as soon as its own fiber starts, so an `/exit` // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and both assertions below describe a live + // Loader service goes with it, and the activation audit describes a live // tree — reading `ctx.loader` here would throw a TypeError over an app that // exited exactly as asked. if (ctx.get('loader') === undefined) return ctx - assertEntriesLoaded(ctx, binName) - assertEntriesActive(ctx, binName) + await assertEntriesActivated(ctx, binName) return ctx } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index d6807bbaed..9a80730a1a 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -157,6 +157,74 @@ describe('assertEntriesLoaded', () => { }) }) +describe('assertEntriesActivated', () => { + interface FakeFiber { + state: number + inject: Record + ctx: { get(name: string): unknown } + await(): Promise + } + + const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({ + loader: { entries: () => entries }, + }) as unknown as Context + + const fiber = ( + state: number, + error?: unknown, + inject: Record = {}, + services: string[] = [], + ): FakeFiber => ({ + state, + inject, + ctx: { get: name => services.includes(name) ? {} : undefined }, + await: error === undefined ? async () => undefined : async () => { throw error }, + }) + + it('passes active entries and ignores disabled entries', async () => { + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(2), options: { name: 'active' } }, + { disabled: true, options: { name: 'disabled' } }, + ]), NAME)).resolves.toBeUndefined() + }) + + it('reports the plugin name and original activation stack instead of fiber state 3', async () => { + const original = new Error('actual plugin failure') + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, original), options: { name: 'broken-plugin' } }, + ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`) + }) + + it('formats stackless and non-Error activation failures', async () => { + const stackless = new Error('stackless failure') + delete (stackless as { stack?: string }).stack + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, stackless), options: { name: 'stackless' } }, + { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } }, + ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`) + }) + + it('reports unresolved services for pending entries', async () => { + const expected = [ + `${NAME}: 3 entries did not activate`, + 'waiting: pending (waiting for services: missingA, missingB)', + 'single-wait: pending (waiting for service: missing)', + 'unknown-wait: pending (waiting for services: unknown)', + ].join('\n') + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']), options: { name: 'waiting' } }, + { fiber: fiber(0, undefined, { missing: {} }), options: { name: 'single-wait' } }, + { fiber: fiber(0), options: { name: 'unknown-wait' } }, + ]), NAME)).rejects.toThrow(expected) + }) + + it('retains the numeric diagnostic for a settled unexpected state', async () => { + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(4), options: { name: 'disposed' } }, + ]), NAME)).rejects.toThrow('disposed: fiber state 4') + }) +}) + describe('loadOverlayPatches', () => { it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => { const dir = tmp() From 890e6daa22490560779525de825e3bd75bdd7923 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:05:44 +0800 Subject: [PATCH 61/85] test: normalize client module fixture paths --- packages/client/modules/tests/node-half.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index 37c41d9742..bc829ab7db 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -1,6 +1,6 @@ /** Node-half composition diagnostics for package metadata and built client bundles. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -18,7 +18,7 @@ afterEach(() => { /** Create a resolvable dshClient package whose client export points at the returned path. */ function writePackage(packageName: string): string { - root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-')) + root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-'))) const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) const clientPath = join(pkgRoot, 'lib', 'client.js') mkdirSync(pkgRoot, { recursive: true }) From 975cc88e4568c9f81506e6611e7b9199e1c84930 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:06:25 +0800 Subject: [PATCH 62/85] docs: refresh client module service catalog --- docs/cordis-catalog/services.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8044313759..af814e87f8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba ## `ctx.clientModuleHost` — `ClientModuleHostService` -The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it). +The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** @@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:177`](../../packages/client/modules/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) From 77567b22a1a1ab3621ff256d180a03e2442ba450 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:14:28 +0800 Subject: [PATCH 63/85] test: complete telemetry loader composition --- examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index d9cc454bfb..d1851ac7c9 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -8,6 +8,7 @@ - id: telemetry-redact-rule name: './telemetry-redact-rule.ts' +# Managed child-process groups required by the bash executor. - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' From 4957c012649130dcafc1c66a91554f5f48847aed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:03:34 +0800 Subject: [PATCH 64/85] test(web): refresh goldens for the unified tool-row expand after the locale merge --- .../snapshots/cordis-tool-round/ui.expected.md | 15 +++++++++------ .../snapshots/fresh-round-trip/ui.expected.md | 8 ++++---- .../tests/snapshots/steering/settled.expected.md | 5 +++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 297915e52f..577b600792 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -15,27 +15,30 @@ - img - img - text: "Think The user wants me to:" -- button: +- button "Inspect temporary": - img - img -- text: Inspect temporary + - text: Inspect temporary - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." -- button [expanded]: +- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]': - img -- text: Mount temporary Plugin typescript + - text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" +- text: typescript - button "Copy" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" +- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts). +- button "Inspect" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." -- button: +- button "Unmount temporary Plugin dyn-1": - img - img -- text: Unmount temporary Plugin dyn-1 + - text: Unmount temporary Plugin dyn-1 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 089a9e8efe..bc33fba084 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -15,10 +15,10 @@ - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- img -- text: Bash Echo the test string Done workspace echo WEB_E2E_OK -- button "Copy" -- text: WEB_E2E_OK +- button "Bash Echo the test string": + - img + - img + - text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index aa51d2c489..5e61b89b0b 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -15,10 +15,11 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button: +- button "Ask question 1/1 answered": - img - img -- text: "Ask question 1/1 answered Interjection Interjection: include the word BANANA in your final reply." + - text: Ask question 1/1 answered +- text: "Interjection Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img From e56da2e47ede17b037d0ceb8fa59d3178b63ffef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:19:10 +0800 Subject: [PATCH 65/85] test(client): align popup aria anchors with locale rollout --- packages/client/ui-command/tests/popup-view.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 4d3ff7ff37..0bbe01b4a9 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -167,7 +167,7 @@ describe('PopupSelectView', () => { onSelect, }) await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) - expect(screen.queryByLabelText('/theme options')).toBeNull() + expect(screen.queryByLabelText('/theme 选项')).toBeNull() expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement expect(enable.disabled).toBe(true) @@ -186,7 +186,7 @@ describe('PopupSelectView', () => { await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) fireEvent.click(screen.getByRole('checkbox')) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - expect(screen.getByLabelText('/theme options')).toBeTruthy() + expect(screen.getByLabelText('/theme 选项')).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) expect(screen.getByRole('checkbox').checked).toBe(false) }) From a691e2ab16deb97359bf6889d5ac2810fe98997d Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 18:54:35 +0800 Subject: [PATCH 66/85] fix: preserve plugin activation errors --- .../activation-error.mjs | 6 +++ .../startup-activation-error/cordis.yml | 2 + .../headless-agent/tests/headless.snapshot.ts | 28 ++++++++++++ .../stderr.expected.txt | 3 ++ packages/client/modules/src/index.ts | 19 ++++++-- .../client/modules/tests/node-half.spec.ts | 6 ++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 4 +- packages/ui/app-boot/README.zh.md | 4 +- packages/ui/app-boot/tests/app-boot.spec.ts | 43 +++++++++---------- vendor/README.md | 1 + vendor/loader/src/config/entry.ts | 5 ++- 12 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs create mode 100644 examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs new file mode 100644 index 0000000000..16e5858045 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs @@ -0,0 +1,6 @@ +/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */ +export function apply() { + const failure = new Error('startup activation snapshot failure') + failure.stack = 'Error: startup activation snapshot failure\n at activation-error-fixture' + throw failure +} diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml new file mode 100644 index 0000000000..2738e4a924 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml @@ -0,0 +1,2 @@ +- id: activation-error + name: ./activation-error.mjs diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 4217f24228..06d42ac9cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -33,6 +33,8 @@ 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 startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) +const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) @@ -167,6 +169,32 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('prints the original Loader activation error through the assembled one-shot app', async () => { + const label = 'headless startup activation error snapshot' + let failure: unknown + try { + await runLoaderSmoke({ + label, + tempDirPrefix: 'headless-snapshot-startup-error-', + binScript, + configPath: startupFailureConfigPath, + binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + tsconfigPath, + }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + const message = (failure as Error).message + const prefix = `${label} exited 1. stdout:\n` + const stderrMarker = '\nstderr:\n' + expect(message.startsWith(prefix)).toBe(true) + const stderrAt = message.indexOf(stderrMarker, prefix.length) + expect(stderrAt).toBeGreaterThanOrEqual(prefix.length) + expect(message.slice(prefix.length, stderrAt)).toBe('') + await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('retries a transient provider failure through the one-shot app', async () => { const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt new file mode 100644 index 0000000000..5896d03464 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -0,0 +1,3 @@ +dsh-cli-demo: dsh-cli-demo: 1 entry did not activate +./activation-error.mjs: Error: startup activation snapshot failure + at activation-error-fixture diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index f7488ff2f4..694295e7f2 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,9 @@ interface PkgMeta { immediately: boolean } +/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */ +const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch' + /** Missing built client export, retained as structured data for activation-error grouping. */ class MissingClientBundleError extends Error { constructor( @@ -66,7 +69,11 @@ class MissingClientBundleError extends Error { cause: unknown, ) { super( - `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + [ + `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`, + ` package: ${packageName}`, + ` path: ${clientPath}`, + ].join('\n'), { cause }, ) } @@ -80,7 +87,7 @@ class ClientPackageCompositionError extends AggregateError { const packageNoun = failures.length === 1 ? 'package' : 'packages' const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] if (missingBundles.length > 0) { - lines.push(' client packages requiring a build before source launch:') + lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`) for (const error of missingBundles) { lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) } @@ -353,7 +360,13 @@ export class ClientModuleHostService extends Service { return meta } - /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + /** + * Read the activation-time bundle revision. + * @param pkgName - package that declares the client bundle. + * @param clientPath - absolute path of the built client artifact. + * @returns the bundle content's short hash for use as its revision. + * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged. + */ private initialBundleRevision(pkgName: string, clientPath: string): string { try { return shortHash(readFileSync(clientPath)) diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index bc829ab7db..3eb99c0ead 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -61,7 +61,7 @@ describe('client bundle activation', () => { const secondPath = writePackage(secondName) expect(() => construct([firstName, secondName])).toThrow([ 'client-modules: 2 client packages failed to compose:', - ' client packages requiring a build before source launch:', + ' client bundles not found; run `pnpm run build` before launch:', ` - package: ${firstName}`, ` path: ${firstPath}`, ` - package: ${secondName}`, @@ -79,7 +79,9 @@ describe('client bundle activation', () => { } catch (error) { thrown = error } + expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:') + expect(String(thrown)).toContain(' other failures:') expect(String(thrown)).toContain('EISDIR') - expect(String(thrown)).not.toContain('requiring a build before source launch') + expect(String(thrown)).not.toContain('pnpm run build') }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 4126ecee12..2cbbfa4f4c 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661 -README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6 +README.md: 55211988a7687ba52f13d30931e042f0823e2526 +README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7c4b258de4..55211988a7 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | +| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -The guards preserve two Loader failure classes. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. +Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b1b1221779..92e2ba5609 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,14 +10,14 @@ | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 | +| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。 +Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 9a80730a1a..8edbb264cd 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -300,31 +300,28 @@ describe('boot', () => { await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) }) - it('rejects a settled tree with a pending inject and names every missing service', async () => { + it('reports an activation error from a real Loader fiber instead of its numeric state', async () => { const dir = tmp() - writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n") + writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n name: ./broken.mjs\n') + let thrown: unknown + try { + await boot(NAME, join(dir, 'cordis.yml')) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`) + expect(String(thrown)).not.toContain('fiber state 3') + }) + + it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { + const dir = tmp() + writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)') - }) - - it('uses singular diagnostics for one missing pending dependency', () => { - const ctx = { - loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)') - }) - - it('reports unknown pending dependencies and unexpected fiber states', () => { - const entries = [ - { disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } }, - { disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } }, - ] - const ctx = { - loader: { entries: () => entries }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([ + `${NAME}: 1 entry did not activate`, + './waiting.mjs: pending (waiting for service: neverProvided)', + ].join('\n')) }) }) diff --git a/vendor/README.md b/vendor/README.md index b140c057ab..8b34e14b47 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +10. **`loader/src/config/entry.ts` activation observer**: observes both fulfillment and rejection before notifying Loader reflection. Upstream's `fiber.await().finally(...)` leaves a rejected derived promise unhandled when plugin activation fails, so Node can terminate before the host reads the original error from the fiber. Covered through the real Loader by `packages/ui/app-boot/tests/app-boot.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..a8821185c2 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -160,10 +160,11 @@ export class Entry { } finally { this._initTask = undefined } - this.fiber?.await().finally(() => { + const notify = () => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - }) + } + void this.fiber?.await().then(notify, notify) } private async _init() { From e423d1011190894f82fe8e447b706dfce011746f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:30:55 +0800 Subject: [PATCH 67/85] fix: ci --- docs/config-catalog.md | 2 ++ docs/cordis-catalog/services.md | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f1d901d326..243a4cc28b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -569,6 +569,8 @@ export interface Config { host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** EADDRINUSE handling: fail immediately or try consecutive ports through 65535. */ + portConflict: 'fail' | 'increment' /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ distIndex: string } diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index af814e87f8..1ecffa4426 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:177`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -739,7 +739,7 @@ Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/ind ## `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). Listen conflict handling follows the explicit config policy; every unhandled listen failure throws out of init — a FAILED fiber the boot's activation audit reports. ```ts cordis-catalog /** @@ -759,7 +759,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:58`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` From c92a6c3b7697c050c60b2d4b1bfa3435efea53ed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:35:53 +0800 Subject: [PATCH 68/85] fix(app-boot): preserve published loader composition --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- .../headless-agent/tests/headless.snapshot.ts | 34 ++++-------- .../tests/loader-composition.spec.ts | 1 + packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 4 +- packages/ui/app-boot/README.zh.md | 4 +- packages/ui/app-boot/src/index.ts | 54 ++++++++++++++++--- packages/ui/app-boot/tests/app-boot.spec.ts | 41 +++++++++----- packages/ui/app-boot/tsdown.config.ts | 19 +++++++ vendor/README.md | 1 - vendor/loader/src/config/entry.ts | 5 +- 13 files changed, 116 insertions(+), 59 deletions(-) create mode 100644 packages/ui/app-boot/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index dcb580be3b..8ac6ef31e0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 6617e0c167e39d11c2261c9b8f433f1b68185ea7 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7b693b8869613b4352d65ba7d352bd1e9fca448b +2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 6617e0c167..0aec471423 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; `installFailLoud` remains the process guard for later unhandled rejections. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 7b693b8869..b20d29e326 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;`installFailLoud` 继续作为进程级保护,处理之后出现的未处理 rejection。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 06d42ac9cc..8b48165a83 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -170,29 +170,17 @@ async function persistedLogs(cwd: string): Promise { describe('headless stream-json snapshots', () => { it('prints the original Loader activation error through the assembled one-shot app', async () => { - const label = 'headless startup activation error snapshot' - let failure: unknown - try { - await runLoaderSmoke({ - label, - tempDirPrefix: 'headless-snapshot-startup-error-', - binScript, - configPath: startupFailureConfigPath, - binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], - tsconfigPath, - }) - } catch (error) { - failure = error - } - expect(failure).toBeInstanceOf(Error) - const message = (failure as Error).message - const prefix = `${label} exited 1. stdout:\n` - const stderrMarker = '\nstderr:\n' - expect(message.startsWith(prefix)).toBe(true) - const stderrAt = message.indexOf(stderrMarker, prefix.length) - expect(stderrAt).toBeGreaterThanOrEqual(prefix.length) - expect(message.slice(prefix.length, stderrAt)).toBe('') - await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected) + const result = await runLoaderSmoke({ + label: 'headless startup activation error snapshot', + tempDirPrefix: 'headless-snapshot-startup-error-', + binScript, + configPath: startupFailureConfigPath, + binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + tsconfigPath, + expectedExitCode: 1, + }) + expect(result.stdout).toBe('') + await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('retries a transient provider failure through the one-shot app', async () => { diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index ce86a8d4ec..59ca1c1992 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx ' config:', ` host: '${bindHost}'`, ' port: 0', + ' portConflict: increment', ` distIndex: '${distIndex}'`, `- name: '${AUTO}'`, '', diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 2cbbfa4f4c..684f322477 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 55211988a7687ba52f13d30931e042f0823e2526 -README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6 +README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3 +README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 55211988a7..54f754842d 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -17,9 +17,9 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. +Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 92e2ba5609..dd56084812 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -17,9 +17,9 @@ | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection。 +Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 0351e83d65..e18346d166 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -160,16 +160,46 @@ export interface FailLoudProcess { exit(code: number): void } +// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep +// exact reasons already folded into the boot diagnostic visible through the +// next process rejection checkpoint so the process guard can coalesce them. +const assembledActivationRejections = new Map() + +function retainAssembledRejection(reason: unknown): void { + assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1) +} + +function releaseAssembledRejection(reason: unknown): void { + const count = assembledActivationRejections.get(reason) + if (count === undefined || count === 1) { + assembledActivationRejections.delete(reason) + } else { + assembledActivationRejections.set(reason, count - 1) + } +} + +async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise { + for (const reason of reasons) retainAssembledRejection(reason) + try { + await new Promise(resolve => setImmediate(resolve)) + } finally { + for (const reason of reasons) releaseAssembledRejection(reason) + } +} + /** * Install before boot to turn a late unhandled plugin-init rejection into one - * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP; - * the returned function removes the handler. + * labelled stderr diagnostic and `exit(1)`. A rejection already included by + * {@link assertEntriesActivated} is ignored during its process checkpoint; + * every other rejection remains fatal. Stdout remains untouched for ACP; the + * returned function removes the handler. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @returns the uninstaller that removes the rejection handler. */ export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { const handler = (err: unknown): void => { + if (assembledActivationRejections.has(err)) return proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) proc.exit(1) } @@ -212,17 +242,20 @@ function formatActivationError(error: unknown): string { * @param ctx - the settled context whose Loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. - * @throws when an entry failed to import, rejected during activation, or did not become active. + * @throws after one process rejection checkpoint when an entry failed to + * import, rejected during activation, or did not become active. */ export async function assertEntriesActivated(ctx: Context, binName: string): Promise { assertEntriesLoaded(ctx, binName) const failures: string[] = [] + const rejectionReasons: unknown[] = [] for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue try { await fiber.await() } catch (error) { + rejectionReasons.push(error) failures.push(`${entry.options.name}: ${formatActivationError(error)}`) continue } @@ -237,6 +270,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro } } if (failures.length > 0) { + if (rejectionReasons.length > 0) { + await observeLoaderRejectionCheckpoint(rejectionReasons) + } const noun = failures.length === 1 ? 'entry' : 'entries' throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) } @@ -250,11 +286,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * bootstrap include is therefore statically imported and mounted as the * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay - * config-relative. A missing fiber rejects here; a later init rejection is - * rethrown with its original stack by {@link assertEntriesActivated}; later - * unhandled rejections remain covered by {@link installFailLoud}. Built bins - * need the Loader's native helper for bare plugin specifiers; relative - * specifiers do not. + * config-relative. The package build embeds Include while leaving Loader + * external, so the built include tree and host share one Loader peer. A + * missing fiber rejects here; a later init rejection is rethrown with its + * original stack by {@link assertEntriesActivated}; later unhandled + * rejections remain covered by {@link installFailLoud}. Built bins need the + * Loader's native helper for bare plugin specifiers; relative specifiers do + * not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 8edbb264cd..1866e50076 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -135,6 +135,33 @@ describe('installFailLoud', () => { uninstallReal() expect(process.listenerCount('unhandledRejection')).toBe(before) }) + + it('does not report an activation rejection shared by entries in the boot audit', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + const error = new Error('assembled activation failure') + const audit = assertEntriesActivated({ + loader: { + entries: () => ['broken-a', 'broken-b'].map(name => ({ + options: { name }, + fiber: { + state: 3, + inject: {}, + ctx: { get: () => undefined }, + await: async () => { throw error }, + }, + })), + }, + } as unknown as Context, NAME) + await Promise.resolve() + await Promise.resolve() + proc.handlers[0]!(error) + expect(proc.written).toEqual([]) + expect(proc.exits).toEqual([]) + await expect(audit).rejects.toThrow('assembled activation failure') + proc.handlers[0]!(error) + expect(proc.exits).toEqual([1]) + }) }) describe('assertEntriesLoaded', () => { @@ -300,20 +327,6 @@ describe('boot', () => { await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) }) - it('reports an activation error from a real Loader fiber instead of its numeric state', async () => { - const dir = tmp() - writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n') - writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n name: ./broken.mjs\n') - let thrown: unknown - try { - await boot(NAME, join(dir, 'cordis.yml')) - } catch (error) { - thrown = error - } - expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`) - expect(String(thrown)).not.toContain('fiber state 3') - }) - it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { const dir = tmp() writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') diff --git a/packages/ui/app-boot/tsdown.config.ts b/packages/ui/app-boot/tsdown.config.ts new file mode 100644 index 0000000000..88492d7c26 --- /dev/null +++ b/packages/ui/app-boot/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +/** + * Embed Include while keeping Loader external so the built include tree and + * app host bind to one Loader peer. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { + alwaysBundle: ['@cordisjs/plugin-include'], + }, +}) diff --git a/vendor/README.md b/vendor/README.md index 8b34e14b47..b140c057ab 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,6 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -10. **`loader/src/config/entry.ts` activation observer**: observes both fulfillment and rejection before notifying Loader reflection. Upstream's `fiber.await().finally(...)` leaves a rejected derived promise unhandled when plugin activation fails, so Node can terminate before the host reads the original error from the fiber. Covered through the real Loader by `packages/ui/app-boot/tests/app-boot.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index a8821185c2..c2959fe61e 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -160,11 +160,10 @@ export class Entry { } finally { this._initTask = undefined } - const notify = () => { + this.fiber?.await().finally(() => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - } - void this.fiber?.await().then(notify, notify) + }) } private async _init() { From 2dab9667f49a0ca1a3174f96e3aa69e863231871 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:29:05 +0800 Subject: [PATCH 69/85] fix: ci --- docs/config-catalog.md | 2 -- docs/cordis-catalog/services.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 243a4cc28b..f1d901d326 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -569,8 +569,6 @@ export interface Config { host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** EADDRINUSE handling: fail immediately or try consecutive ports through 65535. */ - portConflict: 'fail' | 'increment' /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ distIndex: string } diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1ecffa4426..8eed1dab5d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -739,7 +739,7 @@ Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/ind ## `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). Listen conflict handling follows the explicit config policy; every unhandled listen failure throws out of init — a FAILED fiber the boot's activation audit reports. +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. ```ts cordis-catalog /** @@ -759,7 +759,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:58`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` From 05d73d657103eeaa4667468147c6066b77ae60d1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:19:48 +0800 Subject: [PATCH 70/85] fix(app-boot): await only failed activation fibers --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 21 +++++++----- packages/ui/app-boot/tests/app-boot.spec.ts | 33 ++++++++++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 8ac6ef31e0..d50428d5ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e +2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 0aec471423..88f94b1f58 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index b20d29e326..ea2a8f70a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e18346d166..015898f2cb 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -229,6 +229,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { */ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE +const FIBER_FAILED = 3 as FiberState.FAILED /** Render a thrown plugin value without discarding an Error's original stack. */ function formatActivationError(error: unknown): string { @@ -238,7 +239,9 @@ function formatActivationError(error: unknown): string { /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. * Plugin failures include the original thrown stack; pending entries name their - * unresolved services because no plugin error exists for that state. + * unresolved services because no plugin error exists for that state. Active + * entries require no further wait; only failed fibers are awaited to recover + * their private rejection reason. * @param ctx - the settled context whose Loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. @@ -252,15 +255,17 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue - try { - await fiber.await() - } catch (error) { - rejectionReasons.push(error) - failures.push(`${entry.options.name}: ${formatActivationError(error)}`) - continue - } const state = fiber.state if (state === FIBER_ACTIVE) continue + if (state === FIBER_FAILED) { + try { + await fiber.await() + } catch (error) { + rejectionReasons.push(error) + failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + } + continue + } if (state === FIBER_PENDING) { const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) const subject = missing.length === 1 ? 'service' : 'services' diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 1866e50076..c756f28b51 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -209,10 +209,22 @@ describe('assertEntriesActivated', () => { }) it('passes active entries and ignores disabled entries', async () => { + let awaitCalls = 0 + const active = fiber(2) + active.await = async () => { + awaitCalls++ + return undefined + } + const disabled = fiber(3, new Error('disabled failure')) + disabled.await = async () => { + awaitCalls++ + throw new Error('disabled failure') + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(2), options: { name: 'active' } }, - { disabled: true, options: { name: 'disabled' } }, + { fiber: active, options: { name: 'active' } }, + { fiber: disabled, disabled: true, options: { name: 'disabled' } }, ]), NAME)).resolves.toBeUndefined() + expect(awaitCalls).toBe(0) }) it('reports the plugin name and original activation stack instead of fiber state 3', async () => { @@ -232,17 +244,28 @@ describe('assertEntriesActivated', () => { }) it('reports unresolved services for pending entries', async () => { + let awaitCalls = 0 const expected = [ `${NAME}: 3 entries did not activate`, 'waiting: pending (waiting for services: missingA, missingB)', 'single-wait: pending (waiting for service: missing)', 'unknown-wait: pending (waiting for services: unknown)', ].join('\n') + const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']) + const singleWait = fiber(0, undefined, { missing: {} }) + const unknownWait = fiber(0) + for (const item of [waiting, singleWait, unknownWait]) { + item.await = async () => { + awaitCalls++ + return undefined + } + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']), options: { name: 'waiting' } }, - { fiber: fiber(0, undefined, { missing: {} }), options: { name: 'single-wait' } }, - { fiber: fiber(0), options: { name: 'unknown-wait' } }, + { fiber: waiting, options: { name: 'waiting' } }, + { fiber: singleWait, options: { name: 'single-wait' } }, + { fiber: unknownWait, options: { name: 'unknown-wait' } }, ]), NAME)).rejects.toThrow(expected) + expect(awaitCalls).toBe(0) }) it('retains the numeric diagnostic for a settled unexpected state', async () => { From 588e4951df70373bdf8870048141d0010513bd44 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:36:42 +0800 Subject: [PATCH 71/85] test(web): align full-access scenario anchors with locale rollout --- apps/web/tests/access-confirmation.e2e.ts | 27 ++++++++++++++++++----- apps/web/tests/seeded-history.e2e.ts | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index e8a0875c29..89b383da85 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -11,7 +11,24 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { saveFailureShot } from './support.ts' + +/** + * connectFreshWorkspace twin over the product default Chinese locale (the + * shared helper's anchors assume the English page every other scenario + * boots; this scenario deliberately keeps zh, so the localized picker + * copy is the anchor set). + */ +async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise { + await page.getByRole('button', { name: '选择工作区' }).click() + await page.getByRole('menuitem', { name: '新建工作区' }).click() + const dialog = page.getByRole('dialog', { name: '新建工作区' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByLabel('新工作区名称').fill(name) + await dialog.getByRole('button', { name: '创建工作区' }).click() + await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') + .waitFor({ timeout: 15_000 }) +} const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') @@ -36,7 +53,7 @@ describe('web e2e: Full access confirmation', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspaceZh(page) }, 120_000) afterAll(async () => { @@ -46,7 +63,7 @@ describe('web e2e: Full access confirmation', () => { it('requires acknowledgement before the composer picker can enable Full access', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation')) - const access = page.locator('button[aria-label^="Access mode"]').first() + const access = page.locator('button[aria-label^="访问模式"]').first() await access.waitFor({ timeout: 10_000 }) // Normalize the starting preset through the real command path. The @@ -55,7 +72,7 @@ describe('web e2e: Full access confirmation', () => { await access.click() await page.getByRole('menuitem', { name: 'Workspace Write' }).click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Workspace Write') + .toBe('访问模式,当前:Workspace Write') } await access.click() @@ -75,7 +92,7 @@ describe('web e2e: Full access confirmation', () => { expect(await enable.isEnabled()).toBe(true) await enable.click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('Access mode, current: Full access') + .toBe('访问模式,当前:Full access') expect(await dialog.count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3437c41e18..472efc178f 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // where neither half repeats the other (the dispatched `/` and its // argument stay out of the title, and the settlement text never restates // the command's own name). - await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click() + await page.getByRole('button', { name: 'Access mode, current: Full access' }).click() await page.getByRole('menuitem', { name: 'Workspace Write' }).click() await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) // Scoped to the row itself, so unrelated page text that happens to read From 5ccebb4741199c3f4c51704703f547d3eb61d36b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:46:22 +0800 Subject: [PATCH 72/85] test(web): align the search e2e anchor with the localized placeholder --- apps/web/tests/navigation-panes.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 8de890a726..2b98d338d2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -98,7 +98,7 @@ 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('Search names or content', { exact: false }) + const search = page.getByPlaceholder('Search name, keywords', { 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') From b10bcf2d442735d8be20c11974b7d896e692368b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:56:51 +0800 Subject: [PATCH 73/85] test(web): re-record the hero golden for the localized search placeholder --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 78abf19365..70424a4c8c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -11,7 +11,7 @@ - img - button "Search sessions": - img -- textbox "Search names or content…" +- textbox "Search name, keywords..." - tree "Sessions": - treeitem "workspace 1 session" [expanded]: - img From 224e3f85bee5087e71eac6f6d8fd4966f42138da Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:43:51 +0800 Subject: [PATCH 74/85] docs: re-record ui-conversation README pairing after base merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1bdb6eac8e..5095b30cef 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 84671fe067917f4aa38bccfaa413404652456fc2 -README.zh.md: 7aea71890bb725c7a87d9fb12804d9477f4eed7a +README.md: 0e7a5629052112aac3aed832767c20e1096e097f +README.zh.md: d687aea5c46251e6374a293cf87e12101c2540b8 From bf26cfd2ff44ce788ebf4a74b22bae6a4a596929 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:51:58 +0800 Subject: [PATCH 75/85] docs: re-record ui README pairings after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 5095b30cef..9caf61378b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 0e7a5629052112aac3aed832767c20e1096e097f -README.zh.md: d687aea5c46251e6374a293cf87e12101c2540b8 +README.md: f1fdd233b1dcef80e8d4abb263570d4bdf586c7a +README.zh.md: 3e182439e6cbb5df6ef70fc8b188c784f19e57e5 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a93e506412..c665d9a754 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 4e1d85cd830fc720a3abf54b0700ea16789404ae -README.zh.md: 8b8771fa6ec714ba5c4a780135d98109f6c4d186 +README.md: 5406d501eade2881491b3d157edddaa03f235a35 +README.zh.md: 18c7cefa7cdd631405f34f18e7c4c3369c65bc88 From 452ba583d2ba533a37f5e4086333bc7c9f7191b4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:54:36 +0800 Subject: [PATCH 76/85] test(client-ui-conversation): pass locale seat to web-card GenericToolCard/DetailsPanel renders master's locale rollout made `t` a required prop on GenericToolCardProps and the details slot; the web-card spec predates it. Supply the makeTranslate seat the sibling suites use. --- .../client/ui-conversation/tests/web-card.spec.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index e92612c810..69df3e5fbf 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -20,11 +20,17 @@ import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' afterEach(cleanup) const SID = 's1' as SessionId +/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */ +const t = makeTranslate(zh, commonZh) + const SEARCH_ARGS = '{"query":"deepseek harness"}' const FETCH_ARGS = '{"url":"https://example.com/page"}' @@ -164,7 +170,7 @@ describe('chat row web body', () => { // card is resident there too. const view = render() + }), 'fx-web')} t={t} />) expect(view.getByText('Titled')).toBeTruthy() expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() }) @@ -172,7 +178,7 @@ describe('chat row web body', () => { it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { const view = render() + }), 'echo')} t={t} />) expect(view.container.querySelector('[data-web]')).toBeNull() }) }) @@ -199,6 +205,7 @@ describe('DetailsPanel web Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} + t={t} />, ) } From 2cad75a2175b417726320c210e97fe45a9af9cb8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:59:43 +0800 Subject: [PATCH 77/85] =?UTF-8?q?test(client-ui-conversation):=20assert=20?= =?UTF-8?q?localized=20=E8=BE=93=E5=87=BA=20section=20title=20in=20web-car?= =?UTF-8?q?d=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's locale rollout renders the details Output section title through t('details.output') (输出 under the zh seat the test uses). The spec still asserted the English 'Output' literal and failed. Match the sibling terminal-card suite's localized assertion. --- packages/client/ui-conversation/tests/web-card.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 69df3e5fbf..cea95e61f6 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -234,7 +234,7 @@ describe('DetailsPanel web Output section', () => { expect(view.getByText('HTTP 200')).toBeTruthy() // The card is a summary (URL + status only); the panel is the single-call // reading surface, so the fetched body still renders below the card. - const output = view.getByText('Output').closest('section') + const output = view.getByText('输出').closest('section') expect(output?.querySelector('pre')?.textContent).toContain('fetch body') }) @@ -243,7 +243,7 @@ describe('DetailsPanel web Output section', () => { nodes: [settledSearch({ callView: null, resultView: null })], }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) expect(view.container.querySelector('[data-web]')).toBeNull() - const output = view.getByText('Output').closest('section') + const output = view.getByText('输出').closest('section') expect(output?.querySelector('pre')?.textContent).toContain('search text') }) }) From 1f2452bfaee22e313451abf698526c9eeefecbd5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:07:59 +0800 Subject: [PATCH 78/85] docs: add agent note for full-access confirmation --- ...-31-gui-full-access-confirmation.i18n.yaml | 6 ++++ ...2026-07-31-gui-full-access-confirmation.md | 30 +++++++++++++++++++ ...6-07-31-gui-full-access-confirmation.zh.md | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml new file mode 100644 index 0000000000..1c2cbd0216 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.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-31-gui-full-access-confirmation.md +2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306 +2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947 diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md new file mode 100644 index 0000000000..8208a20bee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md @@ -0,0 +1,30 @@ +# Agent Note: GUI Full access risk confirmation + +Status: implemented + +English | [中文](2026-07-31-gui-full-access-confirmation.zh.md) + +## Problem + +Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step. + +## Decision + +**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.** + +- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed. +- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys. +- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending. +- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English. + +## Alternatives considered + +**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards. + +**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import. + +**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged. + +## Consequences + +Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy. diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md new file mode 100644 index 0000000000..8ac115034f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md @@ -0,0 +1,30 @@ +# Agent Note: GUI Full access 风险确认 + +Status: implemented + +[English](2026-07-31-gui-full-access-confirmation.md) | 中文 + +## Problem + +Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。 + +## Decision + +**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。** + +- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。 +- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。 +- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。 +- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知。 + +## Alternatives considered + +**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。 + +**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。 + +**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。 + +## Consequences + +进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案。 From 45627bd93bc472579a2aa1f9874281b37baa6e70 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:17:04 -0700 Subject: [PATCH 79/85] fix(ci): tolerate initializing Lefthook lock --- scripts/install-lefthook.mjs | 73 +++++++++++++++++++++++++++++--- scripts/install-lefthook.spec.ts | 16 +++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index f94fb3bf11..ba4695f4d4 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,6 +1,17 @@ #!/usr/bin/env node import { randomUUID } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + closeSync, + existsSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' import { spawnSync } from 'node:child_process' import { dirname, isAbsolute, join, resolve } from 'node:path' @@ -11,6 +22,7 @@ const OWNERSHIP_MARKER_VERSION = 1 const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 +const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' @@ -303,6 +315,11 @@ function parseInstallLock(record) { return Number.isSafeInteger(owner) ? owner : undefined } +function installLockRecordMayBeIncomplete(record) { + // Exclusive creation exposes the inode before its owner record is fully written. + return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record)) +} + function lockOwnerIsAlive(owner) { try { process.kill(owner, 0) @@ -351,11 +368,29 @@ async function acquireInstallLock(commonDirectory) { const lockPath = join(commonDirectory, INSTALL_LOCK) const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS const ownedRecord = `${String(process.pid)} ${randomUUID()}\n` + let initializingLock while (true) { try { - writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 }) - const ownedStat = installLockStat(lockPath) - if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) { + const lockHandle = openSync(lockPath, 'wx', 0o600) + let ownedStat + try { + ownedStat = fstatSync(lockHandle) + const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0) + if (writeDelay > 0) { + await new Promise(resolveWait => setTimeout(resolveWait, writeDelay)) + } + writeFileSync(lockHandle, ownedRecord) + } finally { + closeSync(lockHandle) + } + const publishedStat = installLockStat(lockPath) + if ( + publishedStat === undefined + || !publishedStat.isFile() + || publishedStat.isSymbolicLink() + || publishedStat.dev !== ownedStat.dev + || publishedStat.ino !== ownedStat.ino + ) { throw lockOwnershipChangedError(lockPath) } return () => releaseInstallLock(lockPath, ownedRecord, ownedStat) @@ -368,8 +403,36 @@ async function acquireInstallLock(commonDirectory) { } const existingRecord = readInstallLock(lockPath) if (existingRecord === undefined) continue + const verifiedStat = installLockStat(lockPath) + if (verifiedStat === undefined) continue + if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) { + throw manualLockRecoveryError(lockPath, 'invalid') + } + if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue const owner = parseInstallLock(existingRecord) - if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid') + if (owner === undefined) { + if (!installLockRecordMayBeIncomplete(existingRecord)) { + throw manualLockRecoveryError(lockPath, 'invalid') + } + const now = Date.now() + if ( + initializingLock === undefined + || initializingLock.dev !== existingStat.dev + || initializingLock.ino !== existingStat.ino + ) { + initializingLock = { + deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS, + dev: existingStat.dev, + ino: existingStat.ino, + } + } + if (now >= initializingLock.deadline) { + throw manualLockRecoveryError(lockPath, 'invalid') + } + await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS)) + continue + } + initializingLock = undefined if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale') if (Date.now() >= deadline) { throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index e38a958410..d76c16bba5 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -307,6 +307,22 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }, 15_000) + it('waits for a concurrent installer to finish publishing its lock record', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const publishing = runInstaller(fixture, fixture.main, { + DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200', + }) + await waitForPath(lockPath) + expect(readFileSync(lockPath, 'utf8')).toBe('') + + const waiting = runInstaller(fixture, fixture.linked) + const results = await Promise.all([publishing, waiting]) + + for (const result of results) expect(result.status, result.stderr).toBe(0) + expect(existsSync(lockPath)).toBe(false) + }) + it('repairs its owned absolute hook path after the checkout moves', async () => { const fixture = createFixture() const oldRoot = fixture.main From e9cbfa153afd8e664b331b8fabe2fddf945cc455 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 11:18:49 +0800 Subject: [PATCH 80/85] docs: address README review --- .../2026-07-22-product-first-root-readme.i18n.yaml | 4 ++-- .../process/2026-07-22-product-first-root-readme.md | 2 +- .../process/2026-07-22-product-first-root-readme.zh.md | 2 +- README.i18n.yaml | 4 ++-- README.md | 10 +++++----- README.zh.md | 8 ++++---- .../request-response.expected.json | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index b38df9878c..70c5ce5a90 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 34bee8210615f4c9b4a2a9389e6edd962850fdc5 -2026-07-22-product-first-root-readme.zh.md: be0c6189f5feef9b923f0081e224a7e042aef001 +2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e +2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 34bee82106..32542a4501 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -14,7 +14,7 @@ The root README preserves its existing structure, order, and wording wherever th A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing. -The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command after real PTY validation; the Web instructions build the default active checkout once and then run `dsh web`, matching a production build and HTTP smoke. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. +The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index be0c6189f5..1c4d5fa538 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -14,7 +14,7 @@ Status: implemented 安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。既有的开发阶段声明明确说明 DeepSeek Harness 处于内测阶段。 -用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。经真实 PTY 验证,安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建一次默认活动检出,然后运行 `dsh web`,该路径已经过生产构建与 HTTP 冒烟验证。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 +用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 1575c6d5d3..66400262db 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: c89a7bad2b06a1d9aeaf74b9450c6362f8fbeb6b -README.zh.md: 85156ca3f6b40801da0f773601301f8cfe3d9c65 +README.md: baf5d79b157ae845cc837261452853afd48dbe46 +README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd diff --git a/README.md b/README.md index c89a7bad2b..baf5d79b15 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This version is still in internal testing. Some features remain unfinished, and “As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs. -We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it. +We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it. ## Install @@ -39,7 +39,7 @@ For the recommended local interface, build the active checkout after installatio dsh web ``` -The build path above is the installer's default; see [`scripts/install.sh`](scripts/install.sh) for alternate locations. The Web UI is served at `http://127.0.0.1:3080` by default. +The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. ### TUI @@ -59,17 +59,17 @@ dsh -p "summarize this workspace" ### Automation and SDKs -From a source checkout, start the ACP automation server: +From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server: ```sh pnpm run demo:acp ``` -The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions. +The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions. ## Why DeepSeek Harness -Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode. +Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode. - **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. - **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log). diff --git a/README.zh.md b/README.zh.md index 85156ca3f6..57d7bcf44c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -39,7 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m dsh web ``` -上述构建路径使用安装器的默认安装位置;如需使用其他位置,请参阅 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 ### TUI @@ -59,17 +59,17 @@ dsh -p "summarize this workspace" ### 自动化与 SDK -从源码检出中启动 ACP(Agent Client Protocol)自动化服务器: +在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器: ```sh pnpm run demo:acp ``` -[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。 +[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。 ## 为什么选择 DeepSeek Harness -内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。 +内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。 - **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 - **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index fc61f9d6bc..1cc80d9286 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe build path above is the installer's default; see [`scripts/install.sh`](scripts/install.sh) for alternate locations. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable TUI, headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述构建路径使用安装器的默认安装位置;如需使用其他位置,请参阅 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n从源码检出中启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 TUI、headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

                      \n \"DeepSeek\n

                      \n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

                      \n \"DeepSeek\n

                      \n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", From 6f0405ed842f61297a0296b9000bc043f868762c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 11:27:13 +0800 Subject: [PATCH 81/85] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9caf61378b..cfbff2117e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: f1fdd233b1dcef80e8d4abb263570d4bdf586c7a -README.zh.md: 3e182439e6cbb5df6ef70fc8b188c784f19e57e5 +README.md: 64440a3d69d78871bdc4777f88bb65c71b02fb69 +README.zh.md: dde2d38d9fae102dbfd49e9c5cacf16385ef3ef8 From aea9cf5e6c98754b762550ce3447ee0442b49b3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:30:08 +0800 Subject: [PATCH 82/85] test(web): carry Full access label into plan-active golden --- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 8be9ff8b86..15bee7afe4 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Commands": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode on, press to turn off": Plan - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash From 322950487ee777673c067c71dbeea989751582d7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 11:51:44 +0800 Subject: [PATCH 83/85] fix(apiproxy): let commands outlive unary deadline --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 5 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 5 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/fetch/client.ts | 34 ++++++---- .../host/apiproxy/tests/fetch-carrier.spec.ts | 62 +++++++++++++++++++ 8 files changed, 98 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 70618612a2..e603129715 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d +2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index b9718da472..b7081591cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -204,7 +204,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa | `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form | | `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` | | `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` | -| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) | +| unary deadline | Ordinary unary calls use `AbortSignal.timeout` (default 30s, constructor-tunable); user-paced `host.pickDirectory` and `command.execute` omit that deadline but keep caller/connection cancellation; streams have no deadline | | `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority | ### The instance-level envelope observation aspect @@ -234,7 +234,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation ## Consequences -Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. +Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. ## Alternatives considered @@ -252,3 +252,4 @@ Every client shape consumes one contract: adding a unary method is a five-step m | A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax | | Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer | | A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope | +| Applying the 30-second transport deadline to `command.execute` | Command duration is operation work, not a transport-health budget; the deadline kills valid long-running handlers, while caller/connection cancellation already supplies the required stop path | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 2add148054..89557182ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -202,7 +202,7 @@ export type ResponseValue = | `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 | | `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` | | `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse | -| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) | +| unary 时限 | 普通 unary 调用使用 `AbortSignal.timeout`(默认 30s,构造参数可调);由用户掌控节奏的 `host.pickDirectory` 和 `command.execute` 不设该时限,但保留调用方/连接取消;流不设时限 | | `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority | ### 实例级 envelope 观测切面 @@ -232,7 +232,7 @@ export type ResponseValue = ## Consequences -所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 +所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 ## Alternatives considered @@ -250,3 +250,4 @@ export type ResponseValue = | DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 | | cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 | | createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 | +| 对 `command.execute` 应用 30 秒传输时限 | 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径 | diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..780e584de7 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: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: 926c631e4784d5ca9b52b1af93487ce4887511b1 +README.zh.md: 030e6f9dab7e659e8ea6bc2543c676f35ade4b39 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..926c631e47 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,11 +28,11 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches. -Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -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). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +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). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..030e6f9dab 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,11 +28,11 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 -目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 97037c10eb..5261658ee0 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -59,9 +59,10 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary * methods take the business payload directly — the carrier mints the rpcId and wraps the * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods and respond accept an optional external AbortSignal as the last parameter - * (merged with the instance timeout via AbortSignal.any; same "signal rides beside the - * request, never on the wire" discipline as the stream signatures). + * Unary methods and respond accept an optional external AbortSignal as the last parameter. + * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls + * carry only that external signal. In both cases the signal rides beside the request, never + * on the wire, like the stream signatures. * Stream methods accept an optional onOpen callback: it fires once the SSE transport is * readable (response headers received, before any frame) — the "stream established" signal * connection controllers need for the readiness handshake. Generators are lazy, so the @@ -182,9 +183,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */ + /** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -257,15 +261,15 @@ export abstract class AbstractApiClient implements IApiClient { /** * Shared POST leg of both C→S carriers (callUnary/respond): JSON body, - * timeout merged with the caller's optional external signal, non-2xx → transport throw. + * optional default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined, - useDefaultTimeout = true, + timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { - const requestSignal = useDefaultTimeout + const requestSignal = timeoutPolicy === 'default' ? signal === undefined ? AbortSignal.timeout(this.timeoutMs) : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) @@ -289,11 +293,11 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, - useDefaultTimeout = true, + timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout) + const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -382,7 +386,9 @@ export abstract class AbstractApiClient implements IApiClient { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), // A native system dialog is user-paced and may legitimately stay open // longer than the normal unary deadline. Caller/connection aborts remain. - pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false), + pickDirectory: (payload, signal) => this.callUnary( + 'host.pickDirectory', payload, signal, 'caller-signal-only', + ), listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), @@ -398,7 +404,11 @@ export abstract class AbstractApiClient implements IApiClient { readonly commands: IApiClient['commands'] = { list: (payload, signal) => this.callUnary('command.list', payload, signal), - execute: (payload, signal) => this.callUnary('command.execute', payload, signal), + // Command handlers are user-driven operations and may legitimately exceed + // the transport health deadline. Caller/connection aborts remain. + execute: (payload, signal) => this.callUnary( + 'command.execute', payload, signal, 'caller-signal-only', + ), } readonly skills: IApiClient['skills'] = { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2390d628ac..3a21d6d1ce 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -349,6 +349,68 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } }) }) + it('lets command.execute finish after the 30-second default unary deadline', async () => { + vi.useFakeTimers() + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { + const controller = new AbortController() + setTimeout(() => { + controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')) + }, milliseconds) + return controller.signal + }) + try { + const api = fakeApi() + api.commands.execute = async (request) => { + await new Promise(resolve => setTimeout(resolve, 30_001)) + return { + rpcId: request.rpcId, + result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, + } + } + const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const assertion = expect(execution).resolves.toMatchObject({ + result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + }) + + await Promise.all([ + vi.advanceTimersByTimeAsync(30_001), + assertion, + ]) + expect(timeoutSpy).not.toHaveBeenCalled() + } finally { + timeoutSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps caller and connection aborts on command.execute', async () => { + const api = fakeApi() + const started = Promise.withResolvers() + api.commands.execute = async (request, signal) => { + started.resolve(signal) + 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: {} } }, + } + } + const controller = new AbortController() + const execution = client(api).commands.execute( + { sessionId: 's' as never, line: '/hang' }, + controller.signal, + ) + const handlerSignal = await started.promise + + controller.abort(new Error('connection closed')) + + await expect(execution).rejects.toThrow('connection closed') + expect(handlerSignal.aborted).toBe(true) + }) + it('propagates the carrier Request signal into command.execute', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() From 45e5317ecd4ecef3fcf0a04f88e3e9f6d160fb69 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:52:35 -0700 Subject: [PATCH 84/85] fix(ci): stabilize merged coverage gates --- knip.json | 1 + packages/client/ui-conversation/README.i18n.yaml | 4 ++-- .../tests/workspace-context.spec.ts | 13 +------------ scripts/install-lefthook.spec.ts | 4 ++-- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/knip.json b/knip.json index f5379c8c6e..dbd3643d13 100644 --- a/knip.json +++ b/knip.json @@ -27,6 +27,7 @@ "scripts/**/*.mjs" ], "ignoreDependencies": [ + "lefthook", "playwright" ] }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 98386de138..5137e6b08a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: e165d2856f24231f28f7cd9bb538d75faeeb62c7 -README.zh.md: db1f85e9e9302c37491b395d451bbc2ebf8fdd87 +README.md: 275b08ae891579a6865b8ce2185028ccb88f0a4c +README.zh.md: c8b2540ecfc6966b5b03e80ae45fa320ad1fcc23 diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index edbb4b3145..0ea161c4d2 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -847,18 +847,7 @@ describe('workspace context request injection', () => { it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - const outcome = await Promise.race([ - ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => { - return 'settled' as const - }), - new Promise<'pending'>((resolve) => { - setTimeout(() => { - resolve('pending') - }, 50) - }), - ]) - - expect(outcome).toBe('settled') + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index d76c16bba5..0a116dea86 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -196,7 +196,7 @@ function runInstaller( }) } -describe('worktree-local Lefthook installer', () => { +describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { for (const [label, extraEnv] of [ ['CI', { CI: 'true' }], ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], @@ -305,7 +305,7 @@ describe('worktree-local Lefthook installer', () => { expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) - }, 15_000) + }) it('waits for a concurrent installer to finish publishing its lock record', async () => { const fixture = createFixture() From 01ccfc0ad3030f40d690e98ba839ba367c071584 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:58:28 -0700 Subject: [PATCH 85/85] fix(ci): align Knip config with CI --- knip.json | 1 - 1 file changed, 1 deletion(-) diff --git a/knip.json b/knip.json index dbd3643d13..f5379c8c6e 100644 --- a/knip.json +++ b/knip.json @@ -27,7 +27,6 @@ "scripts/**/*.mjs" ], "ignoreDependencies": [ - "lefthook", "playwright" ] },